Skip to content
Open
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ Use any model you want — [Nous Portal](https://portal.nousresearch.com), [Open
<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>Runs anywhere, not just your laptop</b></td><td>Six 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.</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
63 changes: 63 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand All @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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)
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

ghost Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main already has a multiplex profile scope in gateway/run.py:1413-1444; it binds both the context-local Hermes home and the profile-specific secret scope. This parallel ContextVar does not carry that credential boundary, so please re-scope this work onto the existing mechanism rather than adding a second profile model.

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,
)
Loading