Skip to content

Commit 4cb6631

Browse files
erosikateknium1
authored andcommitted
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 NousResearch#3681 (profiles) with the Honcho identity layer that was part of NousResearch#2845 (named instances), adapted to the merged profiles system.
1 parent 39d4956 commit 4cb6631

3 files changed

Lines changed: 159 additions & 26 deletions

File tree

honcho_integration/cli.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@
1111
from pathlib import Path
1212

1313
from hermes_constants import get_hermes_home
14-
from honcho_integration.client import resolve_config_path, GLOBAL_CONFIG_PATH
14+
from honcho_integration.client import resolve_active_host, resolve_config_path, GLOBAL_CONFIG_PATH, HOST
1515

16-
HOST = "hermes"
16+
17+
def _host_key() -> str:
18+
"""Return the active Honcho host key, derived from the current Hermes profile."""
19+
return resolve_active_host()
1720

1821

1922
def _config_path() -> Path:
@@ -52,7 +55,7 @@ def _write_config(cfg: dict, path: Path | None = None) -> None:
5255

5356
def _resolve_api_key(cfg: dict) -> str:
5457
"""Resolve API key with host -> root -> env fallback."""
55-
host_key = ((cfg.get("hosts") or {}).get(HOST) or {}).get("apiKey")
58+
host_key = ((cfg.get("hosts") or {}).get(_host_key()) or {}).get("apiKey")
5659
return host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
5760

5861

@@ -118,10 +121,10 @@ def cmd_setup(args) -> None:
118121
if not _ensure_sdk_installed():
119122
return
120123

121-
# All writes go to hosts.hermes — root keys are managed by the user
122-
# or the honcho CLI only.
124+
# All writes go to the active host block — root keys are managed by
125+
# the user or the honcho CLI only.
123126
hosts = cfg.setdefault("hosts", {})
124-
hermes_host = hosts.setdefault(HOST, {})
127+
hermes_host = hosts.setdefault(_host_key(), {})
125128

126129
# API key — shared credential, lives at root so all hosts can read it
127130
current_key = cfg.get("apiKey", "")
@@ -148,7 +151,7 @@ def cmd_setup(args) -> None:
148151
if new_workspace:
149152
hermes_host["workspace"] = new_workspace
150153

151-
hermes_host.setdefault("aiPeer", HOST)
154+
hermes_host.setdefault("aiPeer", _host_key())
152155

153156
# Memory mode
154157
current_mode = hermes_host.get("memoryMode") or cfg.get("memoryMode", "hybrid")
@@ -354,9 +357,9 @@ def cmd_peer(args) -> None:
354357
if user_name is None and ai_name is None and reasoning is None:
355358
# Show current values
356359
hosts = cfg.get("hosts", {})
357-
hermes = hosts.get(HOST, {})
360+
hermes = hosts.get(_host_key(), {})
358361
user = hermes.get('peerName') or cfg.get('peerName') or '(not set)'
359-
ai = hermes.get('aiPeer') or cfg.get('aiPeer') or HOST
362+
ai = hermes.get('aiPeer') or cfg.get('aiPeer') or _host_key()
360363
lvl = hermes.get("dialecticReasoningLevel") or cfg.get("dialecticReasoningLevel") or "low"
361364
max_chars = hermes.get("dialecticMaxChars") or cfg.get("dialecticMaxChars") or 600
362365
print("\nHoncho peers\n" + "─" * 40)
@@ -371,20 +374,20 @@ def cmd_peer(args) -> None:
371374
return
372375

373376
if user_name is not None:
374-
cfg.setdefault("hosts", {}).setdefault(HOST, {})["peerName"] = user_name.strip()
377+
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["peerName"] = user_name.strip()
375378
changed = True
376379
print(f" User peer → {user_name.strip()}")
377380

378381
if ai_name is not None:
379-
cfg.setdefault("hosts", {}).setdefault(HOST, {})["aiPeer"] = ai_name.strip()
382+
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["aiPeer"] = ai_name.strip()
380383
changed = True
381384
print(f" AI peer → {ai_name.strip()}")
382385

383386
if reasoning is not None:
384387
if reasoning not in REASONING_LEVELS:
385388
print(f" Invalid reasoning level '{reasoning}'. Options: {', '.join(REASONING_LEVELS)}")
386389
return
387-
cfg.setdefault("hosts", {}).setdefault(HOST, {})["dialecticReasoningLevel"] = reasoning
390+
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["dialecticReasoningLevel"] = reasoning
388391
changed = True
389392
print(f" Dialectic reasoning level → {reasoning}")
390393

@@ -404,7 +407,7 @@ def cmd_mode(args) -> None:
404407

405408
if mode_arg is None:
406409
current = (
407-
(cfg.get("hosts") or {}).get(HOST, {}).get("memoryMode")
410+
(cfg.get("hosts") or {}).get(_host_key(), {}).get("memoryMode")
408411
or cfg.get("memoryMode")
409412
or "hybrid"
410413
)
@@ -419,7 +422,7 @@ def cmd_mode(args) -> None:
419422
print(f" Invalid mode '{mode_arg}'. Options: {', '.join(MODES)}\n")
420423
return
421424

422-
cfg.setdefault("hosts", {}).setdefault(HOST, {})["memoryMode"] = mode_arg
425+
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["memoryMode"] = mode_arg
423426
_write_config(cfg)
424427
print(f" Memory mode → {mode_arg} ({MODES[mode_arg]})\n")
425428

@@ -428,7 +431,7 @@ def cmd_tokens(args) -> None:
428431
"""Show or set token budget settings."""
429432
cfg = _read_config()
430433
hosts = cfg.get("hosts", {})
431-
hermes = hosts.get(HOST, {})
434+
hermes = hosts.get(_host_key(), {})
432435

433436
context = getattr(args, "context", None)
434437
dialectic = getattr(args, "dialectic", None)
@@ -453,11 +456,11 @@ def cmd_tokens(args) -> None:
453456

454457
changed = False
455458
if context is not None:
456-
cfg.setdefault("hosts", {}).setdefault(HOST, {})["contextTokens"] = context
459+
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["contextTokens"] = context
457460
print(f" context tokens → {context}")
458461
changed = True
459462
if dialectic is not None:
460-
cfg.setdefault("hosts", {}).setdefault(HOST, {})["dialecticMaxChars"] = dialectic
463+
cfg.setdefault("hosts", {}).setdefault(_host_key(), {})["dialecticMaxChars"] = dialectic
461464
print(f" dialectic cap → {dialectic} chars")
462465
changed = True
463466

honcho_integration/client.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,28 @@
3131
HOST = "hermes"
3232

3333

34+
def resolve_active_host() -> str:
35+
"""Derive the Honcho host key from the active Hermes profile.
36+
37+
Resolution order:
38+
1. HERMES_HONCHO_HOST env var (explicit override)
39+
2. Active profile name via profiles system -> ``hermes.<profile>``
40+
3. Fallback: ``"hermes"`` (default profile)
41+
"""
42+
explicit = os.environ.get("HERMES_HONCHO_HOST", "").strip()
43+
if explicit:
44+
return explicit
45+
46+
try:
47+
from hermes_cli.profiles import get_active_profile_name
48+
profile = get_active_profile_name()
49+
if profile and profile not in ("default", "custom"):
50+
return f"{HOST}.{profile}"
51+
except Exception:
52+
pass
53+
return HOST
54+
55+
3456
def resolve_config_path() -> Path:
3557
"""Return the active Honcho config path.
3658
@@ -135,40 +157,52 @@ def peer_memory_mode(self, peer_name: str) -> str:
135157
explicitly_configured: bool = False
136158

137159
@classmethod
138-
def from_env(cls, workspace_id: str = "hermes") -> HonchoClientConfig:
160+
def from_env(
161+
cls,
162+
workspace_id: str = "hermes",
163+
host: str | None = None,
164+
) -> HonchoClientConfig:
139165
"""Create config from environment variables (fallback)."""
166+
resolved_host = host or resolve_active_host()
140167
api_key = os.environ.get("HONCHO_API_KEY")
141168
base_url = os.environ.get("HONCHO_BASE_URL", "").strip() or None
169+
effective_workspace = workspace_id
170+
if effective_workspace == HOST and resolved_host != HOST:
171+
effective_workspace = resolved_host
142172
return cls(
143-
workspace_id=workspace_id,
173+
host=resolved_host,
174+
workspace_id=effective_workspace,
144175
api_key=api_key,
145176
environment=os.environ.get("HONCHO_ENVIRONMENT", "production"),
146177
base_url=base_url,
178+
ai_peer=resolved_host,
147179
enabled=bool(api_key or base_url),
148180
)
149181

150182
@classmethod
151183
def from_global_config(
152184
cls,
153-
host: str = HOST,
185+
host: str | None = None,
154186
config_path: Path | None = None,
155187
) -> HonchoClientConfig:
156188
"""Create config from the resolved Honcho config path.
157189
158190
Resolution: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars.
191+
When host is None, derives it from the active Hermes profile.
159192
"""
193+
resolved_host = host or resolve_active_host()
160194
path = config_path or resolve_config_path()
161195
if not path.exists():
162196
logger.debug("No global Honcho config at %s, falling back to env", path)
163-
return cls.from_env()
197+
return cls.from_env(host=resolved_host)
164198

165199
try:
166200
raw = json.loads(path.read_text(encoding="utf-8"))
167201
except (json.JSONDecodeError, OSError) as e:
168202
logger.warning("Failed to read %s: %s, falling back to env", path, e)
169-
return cls.from_env()
203+
return cls.from_env(host=resolved_host)
170204

171-
host_block = (raw.get("hosts") or {}).get(host, {})
205+
host_block = (raw.get("hosts") or {}).get(resolved_host, {})
172206
# A hosts.hermes block or explicit enabled flag means the user
173207
# intentionally configured Honcho for this host.
174208
_explicitly_configured = bool(host_block) or raw.get("enabled") is True
@@ -177,12 +211,12 @@ def from_global_config(
177211
workspace = (
178212
host_block.get("workspace")
179213
or raw.get("workspace")
180-
or host
214+
or resolved_host
181215
)
182216
ai_peer = (
183217
host_block.get("aiPeer")
184218
or raw.get("aiPeer")
185-
or host
219+
or resolved_host
186220
)
187221
linked_hosts = host_block.get("linkedHosts", [])
188222

@@ -242,7 +276,7 @@ def from_global_config(
242276
)
243277

244278
return cls(
245-
host=host,
279+
host=resolved_host,
246280
workspace_id=workspace,
247281
api_key=api_key,
248282
environment=environment,

tests/honcho_integration/test_client.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
HonchoClientConfig,
1212
get_honcho_client,
1313
reset_honcho_client,
14+
resolve_active_host,
1415
resolve_config_path,
1516
GLOBAL_CONFIG_PATH,
1617
HOST,
@@ -372,6 +373,101 @@ def test_from_global_config_uses_local_path(self, tmp_path):
372373
assert config.workspace_id == "local-ws"
373374

374375

376+
class TestResolveActiveHost:
377+
def test_default_returns_hermes(self):
378+
with patch.dict(os.environ, {}, clear=True):
379+
os.environ.pop("HERMES_HONCHO_HOST", None)
380+
os.environ.pop("HERMES_HOME", None)
381+
assert resolve_active_host() == "hermes"
382+
383+
def test_explicit_env_var_wins(self):
384+
with patch.dict(os.environ, {"HERMES_HONCHO_HOST": "hermes.coder"}):
385+
assert resolve_active_host() == "hermes.coder"
386+
387+
def test_profile_name_derives_host(self):
388+
with patch.dict(os.environ, {}, clear=False):
389+
os.environ.pop("HERMES_HONCHO_HOST", None)
390+
with patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"):
391+
assert resolve_active_host() == "hermes.coder"
392+
393+
def test_default_profile_returns_hermes(self):
394+
with patch.dict(os.environ, {}, clear=False):
395+
os.environ.pop("HERMES_HONCHO_HOST", None)
396+
with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
397+
assert resolve_active_host() == "hermes"
398+
399+
def test_custom_profile_returns_hermes(self):
400+
with patch.dict(os.environ, {}, clear=False):
401+
os.environ.pop("HERMES_HONCHO_HOST", None)
402+
with patch("hermes_cli.profiles.get_active_profile_name", return_value="custom"):
403+
assert resolve_active_host() == "hermes"
404+
405+
def test_profiles_import_failure_falls_back(self):
406+
import importlib
407+
import sys
408+
with patch.dict(os.environ, {}, clear=False):
409+
os.environ.pop("HERMES_HONCHO_HOST", None)
410+
# Temporarily remove hermes_cli.profiles to simulate import failure
411+
saved = sys.modules.get("hermes_cli.profiles")
412+
sys.modules["hermes_cli.profiles"] = None # type: ignore
413+
try:
414+
assert resolve_active_host() == "hermes"
415+
finally:
416+
if saved is not None:
417+
sys.modules["hermes_cli.profiles"] = saved
418+
else:
419+
sys.modules.pop("hermes_cli.profiles", None)
420+
421+
422+
class TestProfileScopedConfig:
423+
def test_from_env_uses_profile_host(self):
424+
with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}):
425+
config = HonchoClientConfig.from_env(host="hermes.coder")
426+
assert config.host == "hermes.coder"
427+
assert config.workspace_id == "hermes.coder"
428+
assert config.ai_peer == "hermes.coder"
429+
430+
def test_from_env_default_workspace_preserved_for_default_host(self):
431+
with patch.dict(os.environ, {"HONCHO_API_KEY": "key"}):
432+
config = HonchoClientConfig.from_env(host="hermes")
433+
assert config.host == "hermes"
434+
assert config.workspace_id == "hermes"
435+
436+
def test_from_global_config_reads_profile_host_block(self, tmp_path):
437+
config_file = tmp_path / "config.json"
438+
config_file.write_text(json.dumps({
439+
"apiKey": "shared-key",
440+
"hosts": {
441+
"hermes": {"aiPeer": "hermes", "peerName": "alice"},
442+
"hermes.coder": {
443+
"aiPeer": "hermes.coder",
444+
"peerName": "alice-coder",
445+
"workspace": "coder-ws",
446+
},
447+
},
448+
}))
449+
config = HonchoClientConfig.from_global_config(
450+
host="hermes.coder", config_path=config_file,
451+
)
452+
assert config.host == "hermes.coder"
453+
assert config.workspace_id == "coder-ws"
454+
assert config.ai_peer == "hermes.coder"
455+
assert config.peer_name == "alice-coder"
456+
457+
def test_from_global_config_auto_resolves_host(self, tmp_path):
458+
config_file = tmp_path / "config.json"
459+
config_file.write_text(json.dumps({
460+
"apiKey": "key",
461+
"hosts": {
462+
"hermes.dreamer": {"peerName": "dreamer-user"},
463+
},
464+
}))
465+
with patch("honcho_integration.client.resolve_active_host", return_value="hermes.dreamer"):
466+
config = HonchoClientConfig.from_global_config(config_path=config_file)
467+
assert config.host == "hermes.dreamer"
468+
assert config.peer_name == "dreamer-user"
469+
470+
375471
class TestResetHonchoClient:
376472
def test_reset_clears_singleton(self):
377473
import honcho_integration.client as mod

0 commit comments

Comments
 (0)