Files
hermes-agent/plugins/memory/honcho/client.py
Erosika beab8b6f27 fix(honcho): keep observation flags across a flush rebuild, never orphan an evicted session, namespace dashboard logins
`_flush_session` discarded the observation flags when it rebuilt an evicted SDK session, and the
cached path returned none, so recall fell back to the config snapshot. Both paths now return and
store the flags. A deferred `save()` on a session the cap evicted puts it back in the cache, or
flushes it inline when a newer object owns the key. `save()` and `stop_async_writer()` share the
writer lock, and the writer drains its queue after the join, so a put that raced shutdown is
written. The trim after a flush runs under the cache lock. The shutdown join takes the remaining
budget instead of a fixed ten seconds.

The injection audit file is created owner-only, and `logging: "false"` reads as off. The desktop
passes `<provider>:<user id>` so a basic-auth alice and an OIDC alice are two peers. When a
gateway platform supplies no user id, the peer notice and tool error no longer recommend
peerName, which would merge every user of that gateway onto one peer. README documents
`injection.sessionStart`, `logging`, and what a dashboard login does to peer resolution.
2026-09-13 19:05:39 +05:30

677 lines
32 KiB
Python

"""Honcho client construction and ``HonchoClientConfig`` resolution.
Config file resolution: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars
(HONCHO_API_KEY, HONCHO_ENVIRONMENT). Within a file, host-block fields win over
flat/global fields, which win over defaults.
"""
from __future__ import annotations
import atexit
import contextlib
import hashlib
import ipaddress
import json
import logging
import os
import threading as _threading
import time
import weakref
# --- per-identity client cache ------------------------------------------- One slot per client identity,
# replacing the single process-wide slot that pinned the first profile's workspace and bearer for every
# later profile in multi-profile processes (#69123 multiplexed gateway, #74065 dashboard). The legacy names
# above are retained only for reset bookkeeping.
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
from urllib.parse import urlparse
from agent.memory_provider import spawn_context_thread as _spawn_context_thread
from agent.secret_scope import get_secret
from hermes_cli.profiles import _get_default_hermes_home
from hermes_constants import get_hermes_home
from plugins.memory.honcho.client_cache import (
_DEFAULT_HTTP_TIMEOUT, _client_cache_key, _client_slots, _client_slots_lock,
_honcho_json_timeout_memo, _refresh_oauth, _slot_for,
)
if TYPE_CHECKING:
from honcho import Honcho
logger = logging.getLogger(__name__)
HOST = "hermes"
def _sanitize_url(url: str | None) -> str | None:
"""``url``, or None (with a warning) if it carries non-printable ASCII: a stray terminal
escape in a pasted URL otherwise makes the SDK raise at client construction."""
if url is None or all(0x20 <= ord(c) < 0x7F for c in url):
return url
logger.warning("Honcho base_url contains non-printable characters and will be ignored: %r", url)
return None
def profile_host_key(profile: str | None) -> str:
"""Return the safe Honcho host key for a Hermes profile."""
if not profile or profile in {"default", "custom"}:
return HOST
sanitized = "".join(c if c.isalnum() or c in "_-" else "_" for c in profile).strip("_")
return f"{HOST}_{sanitized or 'profile'}"
def _host_block(raw: dict, host: str) -> dict:
"""Return host config, accepting legacy dot-form profile host keys."""
hosts = raw.get("hosts") or {}
block = hosts.get(host, {})
if block or not host.startswith(f"{HOST}_"):
return block
return hosts.get(f"{HOST}.{host[len(HOST) + 1:]}", {})
def resolve_active_host() -> str:
"""Honcho host key: HERMES_HONCHO_HOST (profile-scoped .env), else the active profile. The config's
``defaultHost`` is honored only for the default profile so named profiles stay isolated — which is
also why the override is read through the secret scope: from raw environ it would fold every
multiplexed profile onto the default profile's host block and peer."""
explicit = (get_secret("HERMES_HONCHO_HOST", "") or "").strip()
if explicit:
return explicit
try:
from hermes_cli.profiles import get_active_profile_name
profile_host = profile_host_key(get_active_profile_name())
except Exception:
profile_host = HOST
if profile_host == HOST:
try:
default_host = str(_read_config(resolve_config_path()).get("defaultHost", "")).strip()
except Exception:
default_host = ""
if default_host:
return default_host
return profile_host
def _read_config(path: Path) -> dict:
"""Parse a honcho.json; {} when absent (parse/OS errors propagate)."""
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
def resolve_global_config_path() -> Path:
"""Return the shared Honcho config path for the current HOME."""
return Path.home() / ".honcho" / "config.json"
def resolve_config_path() -> Path:
"""Active Honcho config path: $HERMES_HOME/honcho.json -> default profile's honcho.json
(host blocks accumulate there via setup/clone) -> ~/.honcho/config.json (also the
first-time-setup write target when nothing exists)."""
local_path = get_hermes_home() / "honcho.json"
if local_path.exists():
return local_path
default_path = _get_default_hermes_home() / "honcho.json"
if default_path != local_path and default_path.exists():
return default_path
return resolve_global_config_path()
# --- config coercion -------------------------------------------------------
_RECALL_MODES = ({"auto": "hybrid"}, {"hybrid", "context", "tools"}, "hybrid")
_OBSERVATION_MODES = (
{"shared": "unified", "separate": "directional", "cross": "directional"},
{"unified", "directional"},
"directional",
)
_VALID_REASONING_LEVELS = ("minimal", "low", "medium", "high", "max")
# Granular observation booleans derived from the legacy string mode; explicit
# per-peer config always wins over these presets.
_OBSERVATION_PRESETS = {
"directional": {"user_observe_me": True, "user_observe_others": True, "ai_observe_me": True, "ai_observe_others": True},
"unified": {"user_observe_me": True, "user_observe_others": False, "ai_observe_me": False, "ai_observe_others": True},
}
def _normalize_choice(val: str, spec: tuple[dict, set, str]) -> str:
"""Map aliases, then fall back to the default for unknown values."""
aliases, valid, default = spec
val = aliases.get(val, val)
return val if val in valid else default
def _first_set(*vals, default):
"""First non-None value, else default."""
return next((val for val in vals if val is not None), default)
def _first_parsed(vals, caster: Callable[[Any], Any], default):
"""First non-None value that ``caster`` accepts, else default."""
for val in (v for v in vals if v is not None):
try:
return caster(val)
except (ValueError, TypeError):
pass
return default
def _positive_float(value: Any) -> float:
parsed = float(value)
if parsed <= 0:
raise ValueError(value)
return parsed
def _resolve_optional_float(*values: Any) -> float | None:
"""First value (blank strings included) that parses as a positive float, else None."""
return _first_parsed(values, _positive_float, None)
def _parse_dialectic_depth_levels(vals, depth: int) -> list[str] | None:
"""First list value, validated and padded/truncated to ``depth``; None if unset."""
for val in vals:
if isinstance(val, list):
levels = [lvl if lvl in _VALID_REASONING_LEVELS else "low" for lvl in val[:depth]]
return levels + ["low"] * (depth - len(levels))
return None
def _resolve_observation(mode: str, observation_obj: dict | None) -> dict:
"""Per-peer observation booleans: ``observation`` object fields override the mode preset."""
preset = _OBSERVATION_PRESETS.get(mode, _OBSERVATION_PRESETS["directional"])
if not isinstance(observation_obj, dict) or not observation_obj:
return dict(preset)
return {
f"{kind}_observe_{who}": (observation_obj.get(kind) or {}).get(json_key, preset[f"{kind}_observe_{who}"])
for kind in ("user", "ai")
for who, json_key in (("me", "observeMe"), ("others", "observeOthers"))
}
class _HostLookup:
"""Host-block-over-root field lookups for one honcho.json."""
def __init__(self, host_block: dict, raw: dict):
self.host, self.raw = host_block, raw
def pick(self, key: str, default=None):
"""Truthy host value, else ``raw.get(key, default)``."""
return self.host.get(key) or self.raw.get(key, default)
def pick_set(self, key: str, default=None):
"""Non-None host value, else ``raw.get(key, default)``."""
return _first_set(self.host.get(key), default=self.raw.get(key, default))
def vals(self, *keys: str) -> list:
"""Host values then root values for the aliased ``keys`` (lookup order)."""
return [self.host.get(k) for k in keys] + [self.raw.get(k) for k in keys]
def flag(self, *keys: str, default: bool) -> bool:
"""First non-None value across host-then-root for the aliased ``keys``, as bool."""
return bool(_first_set(*self.vals(*keys), default=default))
def parsed(self, key: str, caster: Callable[[Any], Any], default):
"""First host-then-root value ``caster`` accepts, else default."""
return _first_parsed(self.vals(key), caster, default)
def present(self, key: str, default=None):
"""Host value if the key is PRESENT there (even empty/None), else root."""
return self.host[key] if key in self.host else self.raw.get(key, default)
def string(self, key: str, default: str = "") -> str:
"""String field where a host-level empty string can override root."""
value = self.present(key, default)
return default if value is None else str(value).strip()
def string_map(self, key: str) -> dict[str, str]:
"""String-to-string map; a host-level map replaces the root map wholesale."""
source = self.present(key)
if not isinstance(source, dict):
return {}
pairs = ((str(k).strip(), str(v).strip() if v is not None else "") for k, v in source.items())
return {k: v for k, v in pairs if k and v}
def _is_local_base_url(base_url: str | None) -> bool:
"""True for loopback/RFC1918/link-local/ULA/CGNAT self-hosted Honcho URLs. Local
deployments can run without auth but the SDK needs a non-empty api_key, so LAN/VPN
URLs get the same placeholder-key treatment as localhost."""
if not base_url:
return False
try:
host = (urlparse(base_url).hostname or "").strip().lower()
except Exception:
host = ""
if host in {"localhost", "127.0.0.1", "::1"}:
return True
try:
ip = ipaddress.ip_address(host)
except ValueError:
return False
# Tailscale/other VPN setups often sit in carrier-grade NAT space (100.64.0.0/10).
cgnat = ip.version == 4 and ipaddress.ip_address("100.64.0.0") <= ip <= ipaddress.ip_address("100.127.255.255")
return ip.is_loopback or ip.is_private or ip.is_link_local or cgnat
def _env_base_url() -> str | None:
"""HONCHO_BASE_URL / HONCHO_URL (the SDK's own var). A self-hosted URL varies per profile, so it is
read through the secret scope: the scoped HONCHO_API_KEY beside it must not be sent to the default
profile's server."""
return (get_secret("HONCHO_BASE_URL", "") or "").strip() or (get_secret("HONCHO_URL", "") or "").strip() or None
def _connection_fields(look: _HostLookup, host: str, path: Path) -> dict[str, Any]:
"""Resolve identity/credential/transport fields (host block -> root -> env)."""
raw, host_block = look.raw, look.host
api_key = look.pick("apiKey") or get_secret("HONCHO_API_KEY")
# Named-profile host blocks do NOT inherit the default host's apiKey (profiles
# are credential-isolated); the failure is silent 401s, so warn loudly.
if not api_key and host_block and host != HOST and _host_block(raw, HOST).get("apiKey"):
logger.warning("Honcho host block '%s' has no apiKey; the default '%s' host's key "
"is NOT inherited (profiles are credential-isolated). Set apiKey on "
"hosts.%s in %s or this profile runs unauthenticated.", host, HOST, host, path)
# The SDK's native format (and Claude Desktop) nests the URL at endpoint.baseUrl;
# read it before the flat Hermes spellings.
endpoint_block = raw.get("endpoint")
native_base_url = endpoint_block.get("baseUrl") if isinstance(endpoint_block, dict) else None
base_url = _sanitize_url(host_block.get("baseUrl") or host_block.get("base_url") or native_base_url
or raw.get("baseUrl") or raw.get("base_url") or _env_base_url())
return {
"workspace_id": look.pick("workspace") or host,
"ai_peer": look.pick("aiPeer") or host,
"api_key": api_key,
"environment": look.pick("environment", "production"),
"base_url": base_url,
"timeout": _resolve_optional_float(*look.vals("timeout", "requestTimeout"), os.environ.get("HONCHO_TIMEOUT")),
# Explicit enabled (host, then root) wins; else auto-enable on key/url.
"enabled": _first_set(*look.vals("enabled"), default=bool(api_key or base_url)),
}
def _behavior_fields(look: _HostLookup, explicitly_configured: bool) -> dict[str, Any]:
"""Resolve memory-behavior tuning fields (host block -> root -> defaults)."""
raw_wf = look.pick("writeFrequency") or "async"
write_frequency: str | int = _first_parsed([raw_wf], int, str(raw_wf))
depth = look.parsed("dialecticDepth", lambda v: max(1, min(int(v), 3)), 1)
# Migration guard: configs that predate observationMode keep the old
# "unified" default; fresh installs get "directional" (all observations on).
observation_mode = _normalize_choice(
look.pick("observationMode") or ("unified" if explicitly_configured else "directional"), _OBSERVATION_MODES)
return {
"peer_name": look.pick("peerName"),
# pinUserPeer is the clearer name; the original pinPeerName stays accepted.
"pin_peer_name": look.flag("pinUserPeer", "pinPeerName", default=False),
"user_peer_aliases": look.string_map("userPeerAliases"),
"runtime_peer_prefix": look.string("runtimePeerPrefix"),
"save_messages": look.pick_set("saveMessages", True),
"write_frequency": write_frequency,
"context_tokens": look.parsed("contextTokens", int, None),
"dialectic_reasoning_level": look.pick("dialecticReasoningLevel") or "low",
"dialectic_dynamic": look.flag("dialecticDynamic", default=True),
"dialectic_max_chars": look.parsed("dialecticMaxChars", int, 600),
"dialectic_depth": depth,
"dialectic_depth_levels": _parse_dialectic_depth_levels(look.vals("dialecticDepthLevels"), depth),
"reasoning_heuristic": look.flag("reasoningHeuristic", default=True),
"reasoning_level_cap": look.pick("reasoningLevelCap") or "high",
"message_max_chars": look.parsed("messageMaxChars", int, 25000),
"dialectic_max_input_chars": look.parsed("dialecticMaxInputChars", int, 10000),
"recall_mode": _normalize_choice(look.pick("recallMode") or "hybrid", _RECALL_MODES),
"recall_sync": look.flag("recallSync", default=False),
"init_on_session_start": look.flag("initOnSessionStart", default=False),
"injection_frequency": look.pick("injectionFrequency", "every-turn"),
"context_cadence": look.parsed("contextCadence", int, 1),
"dialectic_cadence": look.parsed("dialecticCadence", int, 1),
"query_rewrite": look.flag("queryRewrite", default=False),
"first_turn_base_wait": look.parsed("firstTurnBaseWait", lambda v: max(0.0, float(v)), 3.0),
"first_turn_dialectic_wait": look.parsed("firstTurnDialecticWait", lambda v: max(0.0, float(v)), 2.0),
"observation_mode": observation_mode,
**_resolve_observation(observation_mode, look.pick("observation")),
"session_strategy": look.pick("sessionStrategy", "per-directory"),
"session_peer_prefix": look.pick_set("sessionPeerPrefix", False),
"a2a_sessions": look.flag("a2aSessions", default=True),
}
@dataclass
class HonchoClientConfig:
"""Configuration for Honcho client, resolved for a specific host."""
host: str = HOST
workspace_id: str = "hermes"
api_key: str | None = None
environment: str = "production"
base_url: str | None = None # self-hosted override of the environment mapping
timeout: float | None = None # SDK HTTP timeout, seconds
# Identity
peer_name: str | None = None
ai_peer: str = "hermes"
# True: peer_name wins over gateway runtime identity (Telegram UID, ...), so a
# single-user deployment keeps one memory across platforms.
# This keeps memory unified across platforms for single-user deployments where Honcho's one peer-name is
# an unambiguous identity — otherwise each platform would fork memory into its own peer (#14984).
# Default ``False`` preserves existing multi-user behaviour.
pin_peer_name: bool = False
# Gateway runtime user id -> stable Honcho peer; host map replaces root map.
user_peer_aliases: dict[str, str] = field(default_factory=dict)
runtime_peer_prefix: str = "" # prefix for unknown runtime user ids, e.g. "telegram_"
# Toggles
enabled: bool = False
save_messages: bool = True
write_frequency: str | int = "async" # "async" | "turn" | "session" | every-N-turns int
context_tokens: int | None = None # prefetch budget; None = uncapped
# Dialectic (peer.chat) settings
dialectic_reasoning_level: str = "low" # minimal | low | medium | high | max
dialectic_dynamic: bool = True # model may override the level via honcho_reasoning
dialectic_max_chars: int = 600 # auto-injection cap; explicit calls bypass it
dialectic_depth: int = 1 # .chat() passes per cycle (1-3): audit / synthesis / reconcile
dialectic_depth_levels: list[str] | None = None # per-pass levels; None = proportional defaults
reasoning_heuristic: bool = True # scale auto-injected level up on longer queries
reasoning_level_cap: str = "high"
# Honcho API limits (Honcho cloud: 25000 / 10000) — configurable for self-hosts
message_max_chars: int = 25000
dialectic_max_input_chars: int = 10000
# "hybrid" (context + tools) | "context" (no tools) | "tools" (no auto context)
recall_mode: str = "hybrid"
recall_sync: bool = False # bounded current-query automatic recall
init_on_session_start: bool = False # tools mode: init eagerly instead of on first tool call
injection_frequency: str = "every-turn" # or "first-turn"
context_cadence: int = 1 # min turns between peer.context() calls
dialectic_cadence: int = 1 # min turns between dialectic prefetches
query_rewrite: bool = False # rewrite the user message into a retrieval query (one aux LLM call)
# Bounded synchronous waits on turn 1, seconds; 0 = fully async first turn.
first_turn_base_wait: float = 3.0
first_turn_dialectic_wait: float = 2.0
# Legacy string shorthand; the granular per-peer booleans below are preferred
# and map 1:1 to Honcho's SessionPeerConfig.
observation_mode: str = "directional"
user_observe_me: bool = True
user_observe_others: bool = True
ai_observe_me: bool = True
ai_observe_others: bool = True
# Session resolution
session_strategy: str = "per-directory"
session_peer_prefix: bool = False
# Bot-authored DMs write into their own session per sender bot.
a2a_sessions: bool = True
sessions: dict[str, str] = field(default_factory=dict)
raw: dict[str, Any] = field(default_factory=dict)
# A hosts.<host> block or explicit enabled flag, vs auto-enabled from a stray env key.
explicitly_configured: bool = False
# Provenance captured at resolution time; bound consumers use these instead of
# re-resolving (the resolvers read a ContextVar background threads can't see).
# Provenance: WHERE this config was resolved from, captured at resolution time (inside the caller's
# profile scope). Bound consumers (session manager, OAuth refresh paths) use these instead of
# re-resolving resolve_config_path()/get_hermes_home() later — those resolvers read a ContextVar that
# background threads cannot see, so re-resolution from a daemon thread silently lands on the DEFAULT
# profile (#69123, #74065).
config_path: Path | None = None
hermes_home: Path | None = None
def bound_config_path(self) -> Path:
"""Config path this was resolved from; ambient fallback for hand-built configs."""
return self.config_path if self.config_path is not None else resolve_config_path()
@classmethod
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 = get_secret("HONCHO_API_KEY")
base_url = _sanitize_url(_env_base_url())
return cls(
host=resolved_host, workspace_id=workspace_id, api_key=api_key, base_url=base_url,
environment=get_secret("HONCHO_ENVIRONMENT", "") or "production",
timeout=_resolve_optional_float(os.environ.get("HONCHO_TIMEOUT")),
ai_peer=resolved_host, enabled=bool(api_key or base_url),
config_path=resolve_config_path(), hermes_home=get_hermes_home(),
)
@classmethod
def from_global_config(cls, host: str | None = None, config_path: Path | None = None) -> HonchoClientConfig:
"""Config from the resolved Honcho config path, falling back to env. ``host=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(host=resolved_host)
try:
raw = _read_config(path)
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to read %s: %s, falling back to env", path, e)
return cls.from_env(host=resolved_host)
host_block = _host_block(raw, resolved_host)
explicitly_configured = bool(host_block) or raw.get("enabled") is True
look = _HostLookup(host_block, raw)
return cls(
host=resolved_host, **_connection_fields(look, resolved_host, path), **_behavior_fields(look, explicitly_configured),
sessions=raw.get("sessions", {}), raw=raw, explicitly_configured=explicitly_configured,
config_path=path, hermes_home=get_hermes_home(),
)
@staticmethod
def _git_repo_name(cwd: str) -> str | None:
"""Return the git repo root directory name, or None if not in a repo."""
import subprocess
try:
root = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, encoding='utf-8',
errors='replace', cwd=cwd, timeout=5, stdin=subprocess.DEVNULL)
except (OSError, subprocess.TimeoutExpired):
return None
return Path(root.stdout.strip()).name if root.returncode == 0 else None
# Honcho rejects session IDs over 100 chars; long gateway keys (Matrix
# rooms + thread ids, Slack threads) overflow after sanitization.
_HONCHO_SESSION_ID_MAX_LEN, _HONCHO_SESSION_ID_HASH_LEN = 100, 8
@classmethod
def _enforce_session_id_limit(cls, sanitized: str, original: str) -> str:
"""Truncate to the limit with a ``-<sha256 prefix>`` suffix hashed over the ORIGINAL key:
two long keys sharing a prefix stay distinct; keys that sanitize identically still
collide intentionally (same logical session)."""
max_len, hash_len = cls._HONCHO_SESSION_ID_MAX_LEN, cls._HONCHO_SESSION_ID_HASH_LEN
if len(sanitized) <= max_len:
return sanitized
digest = hashlib.sha256(original.encode("utf-8")).hexdigest()[:hash_len]
prefix = sanitized[: max_len - hash_len - 1].rstrip("-")
return f"{prefix}-{digest}"
def _with_peer_prefix(self, name: str) -> str:
return f"{self.peer_name}-{name}" if self.session_peer_prefix and self.peer_name else name
def resolve_session_name(
self, cwd: str | None = None, session_title: str | None = None,
session_id: str | None = None, gateway_session_key: str | None = None,
) -> str | None:
"""Resolve the Honcho session name. Order: gateway session key (per-chat isolation no
cwd/strategy gives) -> per-session strategy's session_id (authoritative, so a generated
title never remaps a live conversation) -> sessions map override -> /title ->
per-repo (git root name) -> per-directory (basename) -> global (workspace)."""
import re
def _slug(text: str) -> str:
return re.sub(r'[^a-zA-Z0-9_-]+', '-', text).strip('-')
cwd = cwd or os.getcwd()
if gateway_session_key and _slug(gateway_session_key):
return self._enforce_session_id_limit(_slug(gateway_session_key), gateway_session_key)
if self.session_strategy == "per-session" and session_id:
return self._with_peer_prefix(session_id)
manual = self.sessions.get(cwd)
if manual:
return manual
if session_title and _slug(session_title):
return self._with_peer_prefix(_slug(session_title))
if self.session_strategy == "per-repo":
return self._with_peer_prefix(self._git_repo_name(cwd) or Path(cwd).name)
if self.session_strategy in {"per-directory", "per-session"}:
return self._with_peer_prefix(Path(cwd).name)
return self.workspace_id
# Threads keyed by the provider or manager that owns them, so shutdown never waits on another agent's work.
_plugin_threads: "weakref.WeakKeyDictionary[Any, weakref.WeakSet]" = weakref.WeakKeyDictionary()
_plugin_threads_lock = _threading.Lock()
def spawn_context_thread(
target, *, name: str, daemon: bool = True, args: tuple = (), owner: Any = None,
) -> "_threading.Thread":
"""agent.memory_provider.spawn_context_thread plus an ``owner``: the thread is registered so
join_plugin_threads can wait on exactly the threads this provider or manager spawned."""
thread = _spawn_context_thread(target, name=name, daemon=daemon, args=args)
if owner is not None:
with _plugin_threads_lock:
_plugin_threads.setdefault(owner, weakref.WeakSet()).add(thread)
return thread
def join_plugin_threads(owners, timeout: float) -> list[str]:
"""Join every live thread spawned for ``owners`` within one shared ``timeout``. Returns the names
of the threads still running when the budget ran out."""
deadline = time.monotonic() + max(0.0, timeout)
me = _threading.current_thread()
with _plugin_threads_lock:
threads = [t for owner in owners if owner is not None
for t in list(_plugin_threads.get(owner, ())) if t.is_alive() and t is not me]
for thread in threads:
thread.join(timeout=max(0.0, deadline - time.monotonic()))
return [t.name for t in threads if t.is_alive()]
def close_honcho_clients() -> None:
"""Close the HTTP pool of every cached client and drop the slots. Process exit only: one client
serves every manager with the same identity, so a per-agent shutdown must never call this."""
with _client_slots_lock:
slots = list(_client_slots.values())
_client_slots.clear()
for slot in slots:
close = getattr(getattr(slot.peek(), "_http", None), "close", None)
if callable(close):
with contextlib.suppress(Exception):
close()
_exit_close_registered = False
def _register_exit_close() -> None:
global _exit_close_registered
with _client_slots_lock:
if _exit_close_registered:
return
_exit_close_registered = True
atexit.register(close_honcho_clients)
def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
"""Get or create the Honcho client for this config's identity. Clients are cached PER
IDENTITY (host, workspace, provenance paths, credential fingerprint, timeout) so
multi-profile processes don't share a first-config-wins client. With no config the active
honcho.json is resolved — correct only on threads that see the profile ContextVar; pass a
bound config elsewhere. Each identity's client is built once under concurrent first calls.
See #69123, #74065.
"""
key = _client_cache_key(config)
slot = _slot_for(key)
cached = slot.peek()
if cached is not None:
_refresh_oauth(config, cached, slot)
refreshed = slot.peek()
if refreshed is not None:
return refreshed
# Slot was reset by a failed in-place rotation — rebuild below.
if config is None:
config = HonchoClientConfig.from_global_config()
# Start with a live access token rather than 401ing an hour in.
_refresh_oauth(config)
if not config.api_key and not config.base_url:
raise ValueError("Honcho API key not found. Get your API key at https://app.honcho.dev, "
"then run 'hermes honcho setup' or set HONCHO_API_KEY. "
"For local instances, set HONCHO_BASE_URL instead.")
return slot.get(lambda: _build_client(config))
def _build_client(config: HonchoClientConfig) -> "Honcho":
"""Construct the SDK client (runs inside the slot factory so racing callers share one)."""
with contextlib.suppress(Exception): # lazy-dep failures fall through to the canonical import error below
from tools.lazy_deps import ensure as _lazy_ensure
_lazy_ensure("memory.honcho", prompt=False)
try:
from honcho import Honcho
except ImportError:
raise ImportError("honcho-ai is required for Honcho integration. Install it with: pip install honcho-ai "
"(or run `hermes honcho setup` to configure).")
# config.yaml honcho.base_url / timeout fill whatever honcho.json left unset.
base_url, timeout = config.base_url, config.timeout
if not base_url or timeout is None:
with contextlib.suppress(Exception):
from hermes_cli.config import load_config
honcho_cfg = load_config().get("honcho", {})
if isinstance(honcho_cfg, dict):
base_url = base_url or _sanitize_url(honcho_cfg.get("base_url", "").strip() or None)
if timeout is None:
timeout = _resolve_optional_float(honcho_cfg.get("timeout"), honcho_cfg.get("request_timeout"))
if timeout is None:
timeout = _DEFAULT_HTTP_TIMEOUT # an unconfigured install must not hang on a stalled request
if base_url:
logger.info("Initializing Honcho client (base_url: %s, workspace: %s)", base_url, config.workspace_id)
else:
# Name the SDK's environment fallback at INFO so a self-hosted user whose
# config wasn't picked up notices they're talking to the public cloud.
logger.info("Initializing Honcho client (host: %s, workspace: %s, base_url unset — SDK will resolve from environment=%s)",
config.host, config.workspace_id, config.environment)
# Local instances need no key but the SDK wants a non-empty string: honor a
# key set EXPLICITLY in honcho.json (host block or root) and treat an
# env-sourced key as likely-cloud, substituting the placeholder.
raw = config.raw or {}
explicit_key = _host_block(raw, config.host).get("apiKey") or raw.get("apiKey")
api_key = "local" if _is_local_base_url(base_url) and not explicit_key else config.api_key
kwargs: dict = {"workspace_id": config.workspace_id, "api_key": api_key, "environment": config.environment, "timeout": timeout}
if base_url:
# The SDK's route builders already carry the version prefix ("/v3/..."), so
# strip a trailing version segment from any base_url to avoid "/v3/v3/...".
import re
kwargs["base_url"] = re.sub(r"/v\d+/*$", "", base_url).rstrip("/")
_register_exit_close()
return Honcho(**kwargs)
def reset_honcho_client() -> None:
"""Reset all cached Honcho clients (tests, OAuth re-login)."""
with _client_slots_lock:
_client_slots.clear()
_honcho_json_timeout_memo.clear()
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
_PLUGIN_COMPAT_LAZY = {
'SingletonSlot': ('plugins.plugin_utils', 'SingletonSlot'),
}
def __getattr__(name): # PEP 562 — lazy so no import cycles
target = _PLUGIN_COMPAT_LAZY.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
import importlib
from hermes_cli.plugin_compat import warn_once
warn_once(__name__, name, *target)
return getattr(importlib.import_module(target[0]), target[1])
# ---- END PLUGIN-COMPAT ----