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:
Erosika
2026-03-30 13:03:34 -04:00
committed by Teknium
parent 661a1b0ba2
commit 18c156af8e
3 changed files with 159 additions and 26 deletions

View File

@@ -11,9 +11,12 @@ import sys
from pathlib import Path
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:
@@ -52,7 +55,7 @@ def _write_config(cfg: dict, path: Path | None = None) -> None:
def _resolve_api_key(cfg: dict) -> str:
"""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", "")
@@ -118,10 +121,10 @@ def cmd_setup(args) -> None:
if not _ensure_sdk_installed():
return
# All writes go to hosts.hermes — root keys are managed by the user
# or the honcho CLI only.
# All writes go to the active host block — root keys are managed by
# the user or the honcho CLI only.
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
current_key = cfg.get("apiKey", "")
@@ -148,7 +151,7 @@ def cmd_setup(args) -> None:
if new_workspace:
hermes_host["workspace"] = new_workspace
hermes_host.setdefault("aiPeer", HOST)
hermes_host.setdefault("aiPeer", _host_key())
# Memory mode
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:
# Show current values
hosts = cfg.get("hosts", {})
hermes = hosts.get(HOST, {})
hermes = hosts.get(_host_key(), {})
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"
max_chars = hermes.get("dialecticMaxChars") or cfg.get("dialecticMaxChars") or 600
print("\nHoncho peers\n" + "" * 40)
@@ -371,12 +374,12 @@ def cmd_peer(args) -> None:
return
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
print(f" User peer → {user_name.strip()}")
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
print(f" AI peer → {ai_name.strip()}")
@@ -384,7 +387,7 @@ def cmd_peer(args) -> None:
if reasoning not in REASONING_LEVELS:
print(f" Invalid reasoning level '{reasoning}'. Options: {', '.join(REASONING_LEVELS)}")
return
cfg.setdefault("hosts", {}).setdefault(HOST, {})["dialecticReasoningLevel"] = reasoning
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["dialecticReasoningLevel"] = reasoning
changed = True
print(f" Dialectic reasoning level → {reasoning}")
@@ -404,7 +407,7 @@ def cmd_mode(args) -> None:
if mode_arg is None:
current = (
(cfg.get("hosts") or {}).get(HOST, {}).get("memoryMode")
(cfg.get("hosts") or {}).get(_host_key(), {}).get("memoryMode")
or cfg.get("memoryMode")
or "hybrid"
)
@@ -419,7 +422,7 @@ def cmd_mode(args) -> None:
print(f" Invalid mode '{mode_arg}'. Options: {', '.join(MODES)}\n")
return
cfg.setdefault("hosts", {}).setdefault(HOST, {})["memoryMode"] = mode_arg
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["memoryMode"] = mode_arg
_write_config(cfg)
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."""
cfg = _read_config()
hosts = cfg.get("hosts", {})
hermes = hosts.get(HOST, {})
hermes = hosts.get(_host_key(), {})
context = getattr(args, "context", None)
dialectic = getattr(args, "dialectic", None)
@@ -453,11 +456,11 @@ def cmd_tokens(args) -> None:
changed = False
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}")
changed = True
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")
changed = True

View File

@@ -31,6 +31,28 @@ GLOBAL_CONFIG_PATH = Path.home() / ".honcho" / "config.json"
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:
"""Return the active Honcho config path.
@@ -135,40 +157,52 @@ class HonchoClientConfig:
explicitly_configured: bool = False
@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)."""
resolved_host = host or resolve_active_host()
api_key = os.environ.get("HONCHO_API_KEY")
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(
workspace_id=workspace_id,
host=resolved_host,
workspace_id=effective_workspace,
api_key=api_key,
environment=os.environ.get("HONCHO_ENVIRONMENT", "production"),
base_url=base_url,
ai_peer=resolved_host,
enabled=bool(api_key or base_url),
)
@classmethod
def from_global_config(
cls,
host: str = HOST,
host: str | None = None,
config_path: Path | None = None,
) -> HonchoClientConfig:
"""Create config from the resolved Honcho config path.
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()
if not path.exists():
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:
raw = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as 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
# intentionally configured Honcho for this host.
_explicitly_configured = bool(host_block) or raw.get("enabled") is True
@@ -177,12 +211,12 @@ class HonchoClientConfig:
workspace = (
host_block.get("workspace")
or raw.get("workspace")
or host
or resolved_host
)
ai_peer = (
host_block.get("aiPeer")
or raw.get("aiPeer")
or host
or resolved_host
)
linked_hosts = host_block.get("linkedHosts", [])
@@ -242,7 +276,7 @@ class HonchoClientConfig:
)
return cls(
host=host,
host=resolved_host,
workspace_id=workspace,
api_key=api_key,
environment=environment,