-
Notifications
You must be signed in to change notification settings - Fork 52.7k
docs+fix(gateway): multi-agent routing guide + use_profile scope fix (6/6) #37502
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
davidgut1982
wants to merge
12
commits into
NousResearch:main
Choose a base branch
from
davidgut1982:feat/mga-6-docs-fixes
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
b556c07
feat(agent): add AgentProfile + ContextVar for per-agent paths
02356abc 7c14ef8
feat(session): thread agent_id through session identity & DB schema
02356abc 3d8bee8
feat(gateway): route inbound messages via routes table + select_agent…
02356abc 8c7c381
feat(gateway): GatewayRunner loads registry, binds profile, propagate…
02356abc acac30e
feat(cli): add hermes agent subcommand for multi-agent management
02356abc 4a6643f
feat(cron+delivery): propagate agent_id through scheduled jobs & deli…
02356abc d68ceae
feat(gateway): wire api_server adapter to multi-agent routing
davidgut1982 d5bd3af
docs: multi-agent routing guide + sample config
02356abc 43cb58e
fix(gateway): extend use_profile scope + document subagent_id vs agen…
davidgut1982 88f7b51
fix(gateway): guard _attach_agent_id against missing MGA routing attrs
davidgut1982 443e668
fix(cron): use all_jobs in sequential/parallel partition (repair due_…
davidgut1982 e2dc72f
fix(cron): expect agent_id in delivery target for cron-thread tests
davidgut1982 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, | ||
| ) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.