Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
91 changes: 91 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,97 @@ The core mechanism: `_apply_profile_override()` in `hermes_cli/main.py` sets
`HERMES_HOME` before any module imports. All `get_hermes_home()` references
automatically scope to the active profile.

### Multi-Agent Gateway (one gateway, many agents)

In addition to the process-wide profile binding above, a single gateway
process can serve **multiple agents (profiles) in parallel**. Each turn
runs inside a per-turn ``HERMES_HOME`` override so memory / skills /
SOUL.md / config load from the right profile without rebinding the
process env var.

Layered model:

```
process-level HERMES_HOME → the host profile (gateway config, sessions DB)
contextvar override → per-turn agent profile (memory, skills, soul)
```

Resolution order in ``hermes_constants.get_hermes_home()``:

1. ``gateway.agent_context._AGENT_HOME`` contextvar (per-turn).
2. ``HERMES_HOME`` env var (process-level / legacy).
3. ``~/.hermes`` fallback.

Switching agents in a chat:

| User input | Effect |
|---------------------------|-----------------------------------------------------|
| ``/profile`` | Show the session's active profile + host info |
| ``/profile ls`` | List all available profiles |
| ``/profile coder`` | Bind this session to the ``coder`` profile |
| ``/profile default`` | Reset to the default profile |
| ``@coder fix this`` | Route just this turn to ``coder``; binding intact |

``/profile`` is intentionally the single multi-agent entry point —
naming a separate ``/agent`` command would collide with the existing
``/agents`` (plural, "list running agent tasks") only by an ``s``.

Persistence: the chat's bound agent is stored in
``SessionStore._chat_bindings`` (persisted to
``sessions/chat_bindings.json``) and survives gateway restarts. Inline
``@<name>`` mentions are per-turn and never mutate the binding, but
they DO write into the @-target's own session.

Session isolation: each ``(chat, agent)`` pair owns an independent
``session_id`` and transcript. ``/profile coder`` after talking to
``default`` doesn't extend default's history — it starts (or resumes)
coder's own session. Switching back to ``default`` restores its prior
transcript. This matches the "two independent Telegram bots" mental
model rather than "one bot wearing different hats". The default agent
keeps the legacy ``agent:main:...`` session_key shape so existing
``state.db`` rows and ``sessions.json`` entries continue to work with
zero migration.

Implementation choke points:

* ``gateway/agent_context.py`` — contextvar + ``agent_home_scope``
* ``gateway/agent_registry.py`` — enumerate available profiles
* ``gateway/agent_mention.py`` — parse ``@<name> <msg>``
* ``gateway/agent_response.py`` — prepend ``[<agent_name>] `` to replies
(toggle via ``gateway.show_agent_name`` in ``config.yaml``)
* ``GatewayRunner._resolve_turn_agent`` (in ``gateway/run.py``) —
one function decides which profile runs this turn
* ``GatewayRunner._run_agent`` wraps the executor dispatch in
``agent_home_scope`` so all profile-aware path reads inside the AI
agent thread resolve to the right home.
* ``GatewayRunner._handle_profile_command`` (in ``gateway/run.py``)
implements the bare / ``ls`` / ``<name>`` / ``default`` forms.

Cache invariants:

* AIAgent cache signature includes the active profile name (via the
``agent.profile`` cache-bust key), and the cache key (``session_key``)
is itself per-agent — so different agents on the same chat occupy
distinct cache slots and never reuse each other's frozen system
prompt / tool schemas.
* ``/profile <name>`` evicts the OLD binding agent's cache slot (the
new agent's slot is independent and untouched).

What stays profile-bound (NOT lifted to root):

* Gateway config (``gateway:`` section of ``config.yaml``), platform
tokens, sessions DB. These live in the **host** profile — the one
the gateway process was started under. Memory / skills / soul are
the only things that swap per-turn.

Authoring tips:

* If you add a code path that reads profile data, use
``get_hermes_home()`` so the contextvar override applies automatically.
* If you add a slash command that mutates per-session state and that
state depends on the active agent, evict the agent cache the same
way ``_handle_agent_command`` does.

### Rules for profile-safe code

1. **Use `get_hermes_home()` for all HERMES_HOME paths.** Import from `hermes_constants`.
Expand Down
70 changes: 64 additions & 6 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -5346,17 +5346,75 @@ def show_toolsets(self):
print(" Example: python cli.py --toolsets web,terminal")
print()

def _handle_profile_command(self):
"""Display active profile name and home directory."""
def _handle_profile_command(self, cmd_original: str = "/profile"):
"""Display the active profile, list profiles, or hint at switching.

Forms:
/profile → show active profile + home directory
/profile ls → list every available profile
/profile <name> → CLI cannot switch live (process-wide
HERMES_HOME is fixed); print the
correct relaunch command instead

Live profile switching is a multi-agent **gateway** feature
(per-turn ``agent_home_scope``); the CLI process is bound to one
profile at startup via ``-p <name>`` so an in-place swap would
leave half the agent's state pointing at the old home.
"""
from hermes_constants import display_hermes_home
from hermes_cli.profiles import get_active_profile_name
from gateway.agent_registry import default_registry

registry = default_registry()
registry.refresh()
active = get_active_profile_name()
display = display_hermes_home()
profile_name = get_active_profile_name()

parts = (cmd_original or "/profile").split(maxsplit=1)
arg = parts[1].strip() if len(parts) > 1 else ""

if not arg:
# Bare /profile — show active + home
target = registry.get(active) or registry.default()
print()
print(f" Profile: {active}")
if target.description:
print(f" {target.description}")
print(f" Home: {display}")
print()
print(" Use `/profile ls` to list profiles.")
print()
return

if arg.casefold() in {"ls", "list"}:
profiles = registry.list()
print()
print(" Available profiles:")
for p in profiles:
marker = "→" if p.name == active else " "
star = " (default)" if p.is_default else ""
desc = f" — {p.description}" if p.description else ""
print(f" {marker} {p.name}{star}{desc}")
print()
print(f" Active: {active}")
print(" Switch in CLI: relaunch with `hermes -p <name>`")
print(" Switch in gateway: send `/profile <name>` to the bot")
print()
return

# /profile <name> — CLI can't switch live
target = registry.get(arg)
print()
print(f" Profile: {profile_name}")
print(f" Home: {display}")
if target is None:
print(f" Unknown profile {arg!r}.")
available = ", ".join(registry.names()) or "(none)"
print(f" Available: {available}")
else:
print(f" The CLI is bound to one profile per process.")
print(f" To use profile {target.name!r}, relaunch:")
print(f" hermes -p {target.name}")
print()
print(f" (Live switching is a gateway feature: send `/profile {target.name}` to the bot.)")
print()

def show_config(self):
Expand Down Expand Up @@ -7281,7 +7339,7 @@ def process_command(self, command: str) -> bool:
elif canonical == "help":
self.show_help()
elif canonical == "profile":
self._handle_profile_command()
self._handle_profile_command(cmd_original)
elif canonical == "tools":
self._handle_tools_command(cmd_original)
elif canonical == "toolsets":
Expand Down
4 changes: 4 additions & 0 deletions gateway/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
SessionContext,
SessionStore,
SessionResetPolicy,
build_chat_key,
build_session_context_prompt,
build_session_key,
)
from .delivery import DeliveryRouter, DeliveryTarget

Expand All @@ -28,7 +30,9 @@
"SessionContext",
"SessionStore",
"SessionResetPolicy",
"build_chat_key",
"build_session_context_prompt",
"build_session_key",
# Delivery
"DeliveryRouter",
"DeliveryTarget",
Expand Down
78 changes: 78 additions & 0 deletions gateway/agent_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""
Per-turn agent context — lets a single gateway process serve multiple
profiles (memory/skills/soul) without rebinding the process-wide
``HERMES_HOME`` env var.

The mechanism is a single ``ContextVar[Optional[Path]]``. The gateway
runtime sets this contextvar before invoking each ``AIAgent``, and
``hermes_constants.get_hermes_home()`` consults it first so that every
profile-aware path resolution (memory, skills, soul, sessions if scoped,
…) automatically picks up the active agent's home directory.

Design constraints:

- **Import-safe.** No transitive imports of anything that calls
``get_hermes_home()`` at module load time, otherwise the override path
in ``hermes_constants`` would create a circular import.
- **Thread/Executor safe.** ``contextvars`` propagate through
``asyncio.to_thread`` / ``run_in_executor`` automatically when the
caller uses ``contextvars.copy_context()`` (which the gateway already
does in its background work paths). Bare ``threading.Thread`` does NOT
propagate; callers spawning raw threads must capture and re-set via
``current_agent_home()`` themselves.
- **Backward compatible.** When the contextvar is unset (the default),
``get_hermes_home()`` falls back to the existing ``HERMES_HOME`` env
var path — so single-profile gateways and CLI invocations behave
exactly as before.
"""

from __future__ import annotations

import logging
from contextlib import contextmanager
from contextvars import ContextVar
from pathlib import Path
from typing import Iterator, Optional

logger = logging.getLogger(__name__)


_AGENT_HOME: ContextVar[Optional[Path]] = ContextVar(
"hermes_agent_home", default=None
)


def current_agent_home() -> Optional[Path]:
"""Return the active agent's HERMES_HOME, or ``None`` if unset.

Consulted from ``hermes_constants.get_hermes_home()`` to override
the process-wide env var when a gateway turn is running on behalf
of a specific profile.
"""
return _AGENT_HOME.get()


@contextmanager
def agent_home_scope(home: Path) -> Iterator[Path]:
"""Run a block with ``current_agent_home()`` set to ``home``.

Restores the previous value on exit (supports nesting, e.g. an
orchestrator agent that delegates to a sub-agent on a different
profile).
"""
resolved = Path(home)
token = _AGENT_HOME.set(resolved)
try:
yield resolved
finally:
_AGENT_HOME.reset(token)


def reset_agent_home() -> None:
"""Clear the active agent home (back to env-var fallback).

Intended for tests and gateway shutdown. Production callers should
use ``agent_home_scope`` so the previous value is restored
automatically.
"""
_AGENT_HOME.set(None)
94 changes: 94 additions & 0 deletions gateway/agent_mention.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"""
``@<agent> <message>`` inline routing for the multi-agent gateway.

A user types ``@coder fix this bug`` in any chat. The gateway routes
that single turn to the ``coder`` agent (profile) without changing the
session's persistent ``active_agent``. The user's *next* unprefixed
message reverts to whatever the session is bound to via ``/agent``.

The parser is deliberately strict:

* Only the literal ``@`` at message start counts — ``email@host.com``
references mid-sentence don't trigger routing.
* The target must be a registered agent name (canonicalised via
``AgentRegistry``). Unknown ``@foo`` mentions are left untouched —
the message proceeds as a normal user message so the agent can
decide how to interpret it. This means users can still address
external people / handles in chat without the gateway eating their
message.

Wire-up: callers do
parsed = parse_agent_mention(event.text, registry)
if parsed.target_agent:
event.text = parsed.stripped_text
# run this turn with agent_home_scope(registry.get(...).home)
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Optional

from gateway.agent_registry import AgentProfile, AgentRegistry


# `^@<name>\s+<rest>` — name limited to the on-disk profile id alphabet.
# Anchored at start so URLs / handles mid-message can't accidentally
# trigger routing. Non-greedy match for whitespace separator.
_MENTION_RE = re.compile(
r"^@([a-z0-9][a-z0-9_-]{0,63})\s+(.+)$",
re.IGNORECASE | re.DOTALL,
)


@dataclass(frozen=True)
class ParsedMention:
"""Result of parsing a user message for an ``@<agent>`` prefix.

``target_agent`` is ``None`` when no mention was found OR when the
mention referenced an unknown agent — both cases pass the original
text through untouched.
"""

target_agent: Optional[AgentProfile]
stripped_text: str
raw_mention: Optional[str] = None # the literal token "@coder" if matched


def parse_agent_mention(text: str, registry: AgentRegistry) -> ParsedMention:
"""Parse ``text`` for a leading ``@<agent>`` route hint.

Always returns a ``ParsedMention`` — never raises. Callers can
treat ``target_agent is None`` as "no routing applied".
"""
if not isinstance(text, str) or not text:
return ParsedMention(target_agent=None, stripped_text=text or "")

# Cheap early-exit: must start with literal '@' after optional whitespace
# is stripped — but only the leading whitespace, not the body.
leading_ws_match = re.match(r"^(\s*)(@)", text)
if not leading_ws_match:
return ParsedMention(target_agent=None, stripped_text=text)

leading_ws = leading_ws_match.group(1)
body = text[len(leading_ws):]

m = _MENTION_RE.match(body)
if not m:
return ParsedMention(target_agent=None, stripped_text=text)

candidate = m.group(1)
rest = m.group(2)

agent = registry.get(candidate)
if agent is None:
# Mention syntax matched, but target unknown — pass through.
# This is the "user wrote @alice in a normal sentence" case.
return ParsedMention(target_agent=None, stripped_text=text)

return ParsedMention(
target_agent=agent,
stripped_text=rest.strip(),
raw_mention=f"@{candidate}",
)
Loading