mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-14 04:02:26 +00:00
feat(honcho): scope host and peer resolution to active Hermes profile
Derives the Honcho host key from the active Hermes profile so that each profile gets its own Honcho host block, workspace, and AI peer identity. Profile "coder" resolves to host "hermes.coder", reads from hosts["hermes.coder"] in honcho.json, and defaults workspace + aiPeer to the derived host name. Resolution order: HERMES_HONCHO_HOST env var > active profile name > "hermes" (default). Complements #3681 (profiles) with the Honcho identity layer that was part of #2845 (named instances), adapted to the merged profiles system.
This commit is contained in:
parent
661a1b0ba2
commit
18c156af8e
3 changed files with 159 additions and 26 deletions
|
|
@ -11,9 +11,12 @@ import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from hermes_constants import get_hermes_home
|
from hermes_constants import get_hermes_home
|
||||||
from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH
|
from honcho_integration.client import resolve_active_host, resolve_config_path, GLOBAL_CONFIG_PATH, HOST
|
||||||
|
|
||||||
HOST = "hermes"
|
|
||||||
|
def _host_key() -> str:
|
||||||
|
"""Return the active Honcho host key, derived from the current Hermes profile."""
|
||||||
|
return resolve_active_host()
|
||||||
|
|
||||||
|
|
||||||
def _config_path() -> Path:
|
def _config_path() -> Path:
|
||||||
|
|
@ -52,7 +55,7 @@ def _write_config(cfg: dict, path: Path | None = None) -> None:
|
||||||
|
|
||||||
def _resolve_api_key(cfg: dict) -> str:
|
def _resolve_api_key(cfg: dict) -> str:
|
||||||
"""Resolve API key with host -> root -> env fallback."""
|
"""Resolve API key with host -> root -> env fallback."""
|
||||||
host_key = ((cfg.get("hosts") or {}).get(HOST) or {}).get("apiKey")
|
host_key = ((cfg.get("hosts") or {}).get(_host_key()) or {}).get("apiKey")
|
||||||
return host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
|
return host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -118,10 +121,10 @@ def cmd_setup(args) -> None:
|
||||||
if not _ensure_sdk_installed():
|
if not _ensure_sdk_installed():
|
||||||
return
|
return
|
||||||
|
|
||||||
# All writes go to hosts.hermes — root keys are managed by the user
|
# All writes go to the active host block — root keys are managed by
|
||||||
# or the honcho CLI only.
|
# the user or the honcho CLI only.
|
||||||
hosts = cfg.setdefault("hosts", {})
|
hosts = cfg.setdefault("hosts", {})
|
||||||
hermes_host = hosts.setdefault(HOST, {})
|
hermes_host = hosts.setdefault(_host_key(), {})
|
||||||
|
|
||||||
# API key — shared credential, lives at root so all hosts can read it
|
# API key — shared credential, lives at root so all hosts can read it
|
||||||
current_key = cfg.get("apiKey", "")
|
current_key = cfg.get("apiKey", "")
|
||||||
|
|
@ -148,7 +151,7 @@ def cmd_setup(args) -> None:
|
||||||
if new_workspace:
|
if new_workspace:
|
||||||
hermes_host["workspace"] = new_workspace
|
hermes_host["workspace"] = new_workspace
|
||||||
|
|
||||||
hermes_host.setdefault("aiPeer", HOST)
|
hermes_host.setdefault("aiPeer", _host_key())
|
||||||
|
|
||||||
# Memory mode
|
# Memory mode
|
||||||
current_mode = hermes_host.get("memoryMode") or cfg.get("memoryMode", "hybrid")
|
current_mode = hermes_host.get("memoryMode") or cfg.get("memoryMode", "hybrid")
|
||||||
|
|
@ -354,9 +357,9 @@ def cmd_peer(args) -> None:
|
||||||
if user_name is None and ai_name is None and reasoning is None:
|
if user_name is None and ai_name is None and reasoning is None:
|
||||||
# Show current values
|
# Show current values
|
||||||
hosts = cfg.get("hosts", {})
|
hosts = cfg.get("hosts", {})
|
||||||
hermes = hosts.get(HOST, {})
|
hermes = hosts.get(_host_key(), {})
|
||||||
user = hermes.get('peerName') or cfg.get('peerName') or '(not set)'
|
user = hermes.get('peerName') or cfg.get('peerName') or '(not set)'
|
||||||
ai = hermes.get('aiPeer') or cfg.get('aiPeer') or HOST
|
ai = hermes.get('aiPeer') or cfg.get('aiPeer') or _host_key()
|
||||||
lvl = hermes.get("dialecticReasoningLevel") or cfg.get("dialecticReasoningLevel") or "low"
|
lvl = hermes.get("dialecticReasoningLevel") or cfg.get("dialecticReasoningLevel") or "low"
|
||||||
max_chars = hermes.get("dialecticMaxChars") or cfg.get("dialecticMaxChars") or 600
|
max_chars = hermes.get("dialecticMaxChars") or cfg.get("dialecticMaxChars") or 600
|
||||||
print("\nHoncho peers\n" + "─" * 40)
|
print("\nHoncho peers\n" + "─" * 40)
|
||||||
|
|
@ -371,12 +374,12 @@ def cmd_peer(args) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if user_name is not None:
|
if user_name is not None:
|
||||||
cfg.setdefault("hosts", {}).setdefault(HOST, {})["peerName"] = user_name.strip()
|
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["peerName"] = user_name.strip()
|
||||||
changed = True
|
changed = True
|
||||||
print(f" User peer → {user_name.strip()}")
|
print(f" User peer → {user_name.strip()}")
|
||||||
|
|
||||||
if ai_name is not None:
|
if ai_name is not None:
|
||||||
cfg.setdefault("hosts", {}).setdefault(HOST, {})["aiPeer"] = ai_name.strip()
|
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["aiPeer"] = ai_name.strip()
|
||||||
changed = True
|
changed = True
|
||||||
print(f" AI peer → {ai_name.strip()}")
|
print(f" AI peer → {ai_name.strip()}")
|
||||||
|
|
||||||
|
|
@ -384,7 +387,7 @@ def cmd_peer(args) -> None:
|
||||||
if reasoning not in REASONING_LEVELS:
|
if reasoning not in REASONING_LEVELS:
|
||||||
print(f" Invalid reasoning level '{reasoning}'. Options: {', '.join(REASONING_LEVELS)}")
|
print(f" Invalid reasoning level '{reasoning}'. Options: {', '.join(REASONING_LEVELS)}")
|
||||||
return
|
return
|
||||||
cfg.setdefault("hosts", {}).setdefault(HOST, {})["dialecticReasoningLevel"] = reasoning
|
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["dialecticReasoningLevel"] = reasoning
|
||||||
changed = True
|
changed = True
|
||||||
print(f" Dialectic reasoning level → {reasoning}")
|
print(f" Dialectic reasoning level → {reasoning}")
|
||||||
|
|
||||||
|
|
@ -404,7 +407,7 @@ def cmd_mode(args) -> None:
|
||||||
|
|
||||||
if mode_arg is None:
|
if mode_arg is None:
|
||||||
current = (
|
current = (
|
||||||
(cfg.get("hosts") or {}).get(HOST, {}).get("memoryMode")
|
(cfg.get("hosts") or {}).get(_host_key(), {}).get("memoryMode")
|
||||||
or cfg.get("memoryMode")
|
or cfg.get("memoryMode")
|
||||||
or "hybrid"
|
or "hybrid"
|
||||||
)
|
)
|
||||||
|
|
@ -419,7 +422,7 @@ def cmd_mode(args) -> None:
|
||||||
print(f" Invalid mode '{mode_arg}'. Options: {', '.join(MODES)}\n")
|
print(f" Invalid mode '{mode_arg}'. Options: {', '.join(MODES)}\n")
|
||||||
return
|
return
|
||||||
|
|
||||||
cfg.setdefault("hosts", {}).setdefault(HOST, {})["memoryMode"] = mode_arg
|
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["memoryMode"] = mode_arg
|
||||||
_write_config(cfg)
|
_write_config(cfg)
|
||||||
print(f" Memory mode → {mode_arg} ({MODES[mode_arg]})\n")
|
print(f" Memory mode → {mode_arg} ({MODES[mode_arg]})\n")
|
||||||
|
|
||||||
|
|
@ -428,7 +431,7 @@ def cmd_tokens(args) -> None:
|
||||||
"""Show or set token budget settings."""
|
"""Show or set token budget settings."""
|
||||||
cfg = _read_config()
|
cfg = _read_config()
|
||||||
hosts = cfg.get("hosts", {})
|
hosts = cfg.get("hosts", {})
|
||||||
hermes = hosts.get(HOST, {})
|
hermes = hosts.get(_host_key(), {})
|
||||||
|
|
||||||
context = getattr(args, "context", None)
|
context = getattr(args, "context", None)
|
||||||
dialectic = getattr(args, "dialectic", None)
|
dialectic = getattr(args, "dialectic", None)
|
||||||
|
|
@ -453,11 +456,11 @@ def cmd_tokens(args) -> None:
|
||||||
|
|
||||||
changed = False
|
changed = False
|
||||||
if context is not None:
|
if context is not None:
|
||||||
cfg.setdefault("hosts", {}).setdefault(HOST, {})["contextTokens"] = context
|
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["contextTokens"] = context
|
||||||
print(f" context tokens → {context}")
|
print(f" context tokens → {context}")
|
||||||
changed = True
|
changed = True
|
||||||
if dialectic is not None:
|
if dialectic is not None:
|
||||||
cfg.setdefault("hosts", {}).setdefault(HOST, {})["dialecticMaxChars"] = dialectic
|
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["dialecticMaxChars"] = dialectic
|
||||||
print(f" dialectic cap → {dialectic} chars")
|
print(f" dialectic cap → {dialectic} chars")
|
||||||
changed = True
|
changed = True
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,28 @@ GLOBAL_CONFIG_PATH = Path.home() / ".honcho" / "config.json"
|
||||||
HOST = "hermes"
|
HOST = "hermes"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_active_host() -> str:
|
||||||
|
"""Derive the Honcho host key from the active Hermes profile.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
1. HERMES_HONCHO_HOST env var (explicit override)
|
||||||
|
2. Active profile name via profiles system -> ``hermes.<profile>``
|
||||||
|
3. Fallback: ``"hermes"`` (default profile)
|
||||||
|
"""
|
||||||
|
explicit = os.environ.get("HERMES_HONCHO_HOST", "").strip()
|
||||||
|
if explicit:
|
||||||
|
return explicit
|
||||||
|
|
||||||
|
try:
|
||||||
|
from hermes_cli.profiles import get_active_profile_name
|
||||||
|
profile = get_active_profile_name()
|
||||||
|
if profile and profile not in ("default", "custom"):
|
||||||
|
return f"{HOST}.{profile}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return HOST
|
||||||
|
|
||||||
|
|
||||||
def resolve_config_path() -> Path:
|
def resolve_config_path() -> Path:
|
||||||
"""Return the active Honcho config path.
|
"""Return the active Honcho config path.
|
||||||
|
|
||||||
|
|
@ -135,40 +157,52 @@ class HonchoClientConfig:
|
||||||
explicitly_configured: bool = False
|
explicitly_configured: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_env(cls, workspace_id: str = "hermes") -> HonchoClientConfig:
|
def from_env(
|
||||||
|
cls,
|
||||||
|
workspace_id: str = "hermes",
|
||||||
|
host: str | None = None,
|
||||||
|
) -> HonchoClientConfig:
|
||||||
"""Create config from environment variables (fallback)."""
|
"""Create config from environment variables (fallback)."""
|
||||||
|
resolved_host = host or resolve_active_host()
|
||||||
api_key = os.environ.get("HONCHO_API_KEY")
|
api_key = os.environ.get("HONCHO_API_KEY")
|
||||||
base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None
|
base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None
|
||||||
|
effective_workspace = workspace_id
|
||||||
|
if effective_workspace == HOST and resolved_host != HOST:
|
||||||
|
effective_workspace = resolved_host
|
||||||
return cls(
|
return cls(
|
||||||
workspace_id=workspace_id,
|
host=resolved_host,
|
||||||
|
workspace_id=effective_workspace,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
environment=os.environ.get("HONCHO_ENVIRONMENT", "production"),
|
environment=os.environ.get("HONCHO_ENVIRONMENT", "production"),
|
||||||
base_url=base_url,
|
base_url=base_url,
|
||||||
|
ai_peer=resolved_host,
|
||||||
enabled=bool(api_key or base_url),
|
enabled=bool(api_key or base_url),
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_global_config(
|
def from_global_config(
|
||||||
cls,
|
cls,
|
||||||
host: str = HOST,
|
host: str | None = None,
|
||||||
config_path: Path | None = None,
|
config_path: Path | None = None,
|
||||||
) -> HonchoClientConfig:
|
) -> HonchoClientConfig:
|
||||||
"""Create config from the resolved Honcho config path.
|
"""Create config from the resolved Honcho config path.
|
||||||
|
|
||||||
Resolution: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars.
|
Resolution: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars.
|
||||||
|
When host is None, derives it from the active Hermes profile.
|
||||||
"""
|
"""
|
||||||
|
resolved_host = host or resolve_active_host()
|
||||||
path = config_path or resolve_config_path()
|
path = config_path or resolve_config_path()
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
logger.debug("No global Honcho config at %s, falling back to env", path)
|
logger.debug("No global Honcho config at %s, falling back to env", path)
|
||||||
return cls.from_env()
|
return cls.from_env(host=resolved_host)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
logger.warning("Failed to read %s: %s, falling back to env", path, e)
|
logger.warning("Failed to read %s: %s, falling back to env", path, e)
|
||||||
return cls.from_env()
|
return cls.from_env(host=resolved_host)
|
||||||
|
|
||||||
host_block = (raw.get("hosts") or {}).get(host, {})
|
host_block = (raw.get("hosts") or {}).get(resolved_host, {})
|
||||||
# A hosts.hermes block or explicit enabled flag means the user
|
# A hosts.hermes block or explicit enabled flag means the user
|
||||||
# intentionally configured Honcho for this host.
|
# intentionally configured Honcho for this host.
|
||||||
_explicitly_configured = bool(host_block) or raw.get("enabled") is True
|
_explicitly_configured = bool(host_block) or raw.get("enabled") is True
|
||||||
|
|
@ -177,12 +211,12 @@ class HonchoClientConfig:
|
||||||
workspace = (
|
workspace = (
|
||||||
host_block.get("workspace")
|
host_block.get("workspace")
|
||||||
or raw.get("workspace")
|
or raw.get("workspace")
|
||||||
or host
|
or resolved_host
|
||||||
)
|
)
|
||||||
ai_peer = (
|
ai_peer = (
|
||||||
host_block.get("aiPeer")
|
host_block.get("aiPeer")
|
||||||
or raw.get("aiPeer")
|
or raw.get("aiPeer")
|
||||||
or host
|
or resolved_host
|
||||||
)
|
)
|
||||||
linked_hosts = host_block.get("linkedHosts", [])
|
linked_hosts = host_block.get("linkedHosts", [])
|
||||||
|
|
||||||
|
|
@ -242,7 +276,7 @@ class HonchoClientConfig:
|
||||||
)
|
)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
host=host,
|
host=resolved_host,
|
||||||
workspace_id=workspace,
|
workspace_id=workspace,
|
||||||
api_key=api_key,
|
api_key=api_key,
|
||||||
environment=environment,
|
environment=environment,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from honcho_integration.client import (
|
||||||
HonchoClientConfig,
|
HonchoClientConfig,
|
||||||
get_honcho_client,
|
get_honcho_client,
|
||||||
reset_honcho_client,
|
reset_honcho_client,
|
||||||
|
resolve_active_host,
|
||||||
resolve_config_path,
|
resolve_config_path,
|
||||||
GLOBAL_CONFIG_PATH,
|
GLOBAL_CONFIG_PATH,
|
||||||
HOST,
|
HOST,
|
||||||
|
|
@ -372,6 +373,101 @@ class TestResolveConfigPath:
|
||||||
assert config.workspace_id == "local-ws"
|
assert config.workspace_id == "local-ws"
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveActiveHost:
|
||||||
|
def test_default_returns_hermes(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
os.environ.pop("HERMES_HONCHO_HOST", None)
|
||||||
|
os.environ.pop("HERMES_HOME", None)
|
||||||
|
assert resolve_active_host() == "hermes"
|
||||||
|
|
||||||
|
def test_explicit_env_var_wins(self):
|
||||||
|
with patch.dict(os.environ, {"HERMES_HONCHO_HOST": "hermes.coder"}):
|
||||||
|
assert resolve_active_host() == "hermes.coder"
|
||||||
|
|
||||||
|
def test_profile_name_derives_host(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=False):
|
||||||
|
os.environ.pop("HERMES_HONCHO_HOST", None)
|
||||||
|
with patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"):
|
||||||
|
assert resolve_active_host() == "hermes.coder"
|
||||||
|
|
||||||
|
def test_default_profile_returns_hermes(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=False):
|
||||||
|
os.environ.pop("HERMES_HONCHO_HOST", None)
|
||||||
|
with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
|
||||||
|
assert resolve_active_host() == "hermes"
|
||||||
|
|
||||||
|
def test_custom_profile_returns_hermes(self):
|
||||||
|
with patch.dict(os.environ, {}, clear=False):
|
||||||
|
os.environ.pop("HERMES_HONCHO_HOST", None)
|
||||||
|
with patch("hermes_cli.profiles.get_active_profile_name", return_value="custom"):
|
||||||
|
assert resolve_active_host() == "hermes"
|
||||||
|
|
||||||
|
def test_profiles_import_failure_falls_back(self):
|
||||||
|
import importlib
|
||||||
|
import sys
|
||||||
|
with patch.dict(os.environ, {}, clear=False):
|
||||||
|
os.environ.pop("HERMES_HONCHO_HOST", None)
|
||||||
|
# Temporarily remove hermes_cli.profiles to simulate import failure
|
||||||
|
saved = sys.modules.get("hermes_cli.profiles")
|
||||||
|
sys.modules["hermes_cli.profiles"] = None # type: ignore
|
||||||
|
try:
|
||||||
|
assert resolve_active_host() == "hermes"
|
||||||
|
finally:
|
||||||
|
if saved is not None:
|
||||||
|
sys.modules["hermes_cli.profiles"] = saved
|
||||||
|
else:
|
||||||
|
sys.modules.pop("hermes_cli.profiles", None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileScopedConfig:
|
||||||
|
def test_from_env_uses_profile_host(self):
|
||||||
|
with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}):
|
||||||
|
config = HonchoClientConfig.from_env(host="hermes.coder")
|
||||||
|
assert config.host == "hermes.coder"
|
||||||
|
assert config.workspace_id == "hermes.coder"
|
||||||
|
assert config.ai_peer == "hermes.coder"
|
||||||
|
|
||||||
|
def test_from_env_default_workspace_preserved_for_default_host(self):
|
||||||
|
with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}):
|
||||||
|
config = HonchoClientConfig.from_env(host="hermes")
|
||||||
|
assert config.host == "hermes"
|
||||||
|
assert config.workspace_id == "hermes"
|
||||||
|
|
||||||
|
def test_from_global_config_reads_profile_host_block(self, tmp_path):
|
||||||
|
config_file = tmp_path / "config.json"
|
||||||
|
config_file.write_text(json.dumps({
|
||||||
|
"apiKey": "shared-key",
|
||||||
|
"hosts": {
|
||||||
|
"hermes": {"aiPeer": "hermes", "peerName": "alice"},
|
||||||
|
"hermes.coder": {
|
||||||
|
"aiPeer": "hermes.coder",
|
||||||
|
"peerName": "alice-coder",
|
||||||
|
"workspace": "coder-ws",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
config = HonchoClientConfig.from_global_config(
|
||||||
|
host="hermes.coder", config_path=config_file,
|
||||||
|
)
|
||||||
|
assert config.host == "hermes.coder"
|
||||||
|
assert config.workspace_id == "coder-ws"
|
||||||
|
assert config.ai_peer == "hermes.coder"
|
||||||
|
assert config.peer_name == "alice-coder"
|
||||||
|
|
||||||
|
def test_from_global_config_auto_resolves_host(self, tmp_path):
|
||||||
|
config_file = tmp_path / "config.json"
|
||||||
|
config_file.write_text(json.dumps({
|
||||||
|
"apiKey": "key",
|
||||||
|
"hosts": {
|
||||||
|
"hermes.dreamer": {"peerName": "dreamer-user"},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
with patch("honcho_integration.client.resolve_active_host", return_value="hermes.dreamer"):
|
||||||
|
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||||
|
assert config.host == "hermes.dreamer"
|
||||||
|
assert config.peer_name == "dreamer-user"
|
||||||
|
|
||||||
|
|
||||||
class TestResetHonchoClient:
|
class TestResetHonchoClient:
|
||||||
def test_reset_clears_singleton(self):
|
def test_reset_clears_singleton(self):
|
||||||
import honcho_integration.client as mod
|
import honcho_integration.client as mod
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue