232 lines
8.9 KiB
Python
232 lines
8.9 KiB
Python
"""Per-identity Honcho client cache: cache keys, slots, and OAuth refresh hooks.
|
|
|
|
One SingletonSlot 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, #74065). Origin-module symbols are
|
|
imported lazily so tests that monkeypatch ``client.resolve_config_path`` etc.
|
|
keep intercepting.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
import threading
|
|
from typing import TYPE_CHECKING
|
|
|
|
from plugins.plugin_utils import SingletonSlot
|
|
|
|
if TYPE_CHECKING:
|
|
from honcho import Honcho
|
|
|
|
from plugins.memory.honcho.client import HonchoClientConfig
|
|
|
|
logger = logging.getLogger("plugins.memory.honcho.client")
|
|
|
|
# Applied when no timeout is configured anywhere: Honcho calls run on the
|
|
# post-response path, and an uncapped call can block response delivery forever.
|
|
_DEFAULT_HTTP_TIMEOUT = 30.0
|
|
|
|
_client_slots: dict[tuple, SingletonSlot] = {}
|
|
_client_slots_lock = threading.Lock()
|
|
|
|
# honcho.json-derived timeout, keyed PER CONFIG PATH on mtime_ns (-1 = absent) so
|
|
# the per-call staleness check costs one stat(). config.yaml needs no memo:
|
|
# load_config_readonly() is already cached on its files' signatures.
|
|
_honcho_json_timeout_memo: dict[str, tuple[int, float | None]] = {}
|
|
|
|
|
|
def _fingerprint_basis(block: dict, key_fn) -> str:
|
|
"""OAuth grants hash the REFRESH token (stable across access-token rotation,
|
|
changes on re-auth/account switch); static keys hash the key itself."""
|
|
oauth_block = block.get("oauth")
|
|
if isinstance(oauth_block, dict) and oauth_block.get("refreshToken"):
|
|
return f"oauth:{oauth_block['refreshToken']}"
|
|
key = key_fn()
|
|
return f"key:{key}" if key else ""
|
|
|
|
|
|
def _credential_fingerprint(config: HonchoClientConfig | None) -> str:
|
|
"""Stable identity for the credential a client will be built with, or ''.
|
|
|
|
Must NOT change on in-place access-token rotation, but must change on
|
|
account switch so 'hermes honcho setup' yields a NEW cache identity.
|
|
"""
|
|
from plugins.memory.honcho.client import _host_block
|
|
|
|
try:
|
|
if config is not None:
|
|
basis = _fingerprint_basis(_host_block(config.raw or {}, config.host), lambda: config.api_key)
|
|
else:
|
|
# Ambient: correct on main threads; bound configs are the supported
|
|
# path for background threads.
|
|
raw, block = _ambient_host_block()
|
|
if raw is None:
|
|
return ""
|
|
from agent.secret_scope import get_secret
|
|
|
|
basis = _fingerprint_basis(
|
|
block, lambda: block.get("apiKey") or raw.get("apiKey") or get_secret("HONCHO_API_KEY") or ""
|
|
)
|
|
if basis:
|
|
return hashlib.sha256(basis.encode("utf-8")).hexdigest()[:16]
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
|
|
|
|
def _ambient_host_block() -> tuple[dict | None, dict]:
|
|
"""(raw honcho.json, active host block) for the ambient profile; (None, {}) when absent."""
|
|
from plugins.memory.honcho.client import _host_block, resolve_active_host, resolve_config_path
|
|
|
|
path = resolve_config_path()
|
|
if not path.exists():
|
|
return None, {}
|
|
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
return raw, _host_block(raw, resolve_active_host())
|
|
|
|
|
|
def _client_cache_key(config: HonchoClientConfig | None) -> tuple:
|
|
"""Cache identity for a Honcho client build.
|
|
|
|
Explicit configs key on connection identity, provenance paths, effective
|
|
timeout, and the credential fingerprint (the access token itself is NOT in
|
|
the key — in-place rotation must stay within one slot). Ambient callers
|
|
(config=None) key on what from_global_config() would resolve.
|
|
"""
|
|
from plugins.memory.honcho.client import resolve_active_host, resolve_config_path
|
|
|
|
if config is not None:
|
|
return (
|
|
"explicit", config.host, config.workspace_id, config.base_url or "", config.environment,
|
|
str(config.config_path) if config.config_path is not None else "",
|
|
str(config.hermes_home) if config.hermes_home is not None else "",
|
|
_resolve_timeout_from_sources(config), _credential_fingerprint(config),
|
|
)
|
|
return (
|
|
"ambient", str(resolve_config_path()), resolve_active_host(),
|
|
_resolve_timeout_from_sources(None), _credential_fingerprint(None),
|
|
)
|
|
|
|
|
|
def _slot_identity(key: tuple) -> tuple:
|
|
"""(kind, host, paths) — the part of a cache key that survives credential/timeout churn."""
|
|
return key[:3] if key[0] == "ambient" else (key[0], key[1], key[5], key[6])
|
|
|
|
|
|
def _slot_for(key: tuple) -> SingletonSlot:
|
|
"""Return the slot for ``key``, evicting stale same-identity slots.
|
|
|
|
A same (kind, host, paths) identity with a different credential/timeout
|
|
drops the old slot so the replaced client stops being served; otherwise
|
|
credential churn leaks one pinned client per change.
|
|
"""
|
|
identity = _slot_identity(key)
|
|
with _client_slots_lock:
|
|
slot = _client_slots.get(key)
|
|
if slot is None:
|
|
for k in [k for k in _client_slots if k != key and _slot_identity(k) == identity]:
|
|
_client_slots.pop(k, None)
|
|
slot = SingletonSlot()
|
|
_client_slots[key] = slot
|
|
return slot
|
|
|
|
|
|
def _config_yaml_timeout() -> float | None:
|
|
"""Read honcho.timeout / honcho.request_timeout via the cached config loader."""
|
|
from plugins.memory.honcho.client import _resolve_optional_float
|
|
|
|
try:
|
|
from hermes_cli.config import load_config_readonly
|
|
|
|
honcho_cfg = load_config_readonly().get("honcho", {})
|
|
if isinstance(honcho_cfg, dict):
|
|
return _resolve_optional_float(honcho_cfg.get("timeout"), honcho_cfg.get("request_timeout"))
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def _honcho_json_timeout() -> float | None:
|
|
"""Read timeout/requestTimeout from honcho.json (host block wins), memoized on mtime."""
|
|
from plugins.memory.honcho.client import _HostLookup, _resolve_optional_float, resolve_config_path
|
|
|
|
try:
|
|
path = resolve_config_path()
|
|
path_key = str(path)
|
|
try:
|
|
mtime_ns: int = path.stat().st_mtime_ns
|
|
except OSError:
|
|
mtime_ns = -1
|
|
memo = _honcho_json_timeout_memo.get(path_key)
|
|
if memo is not None and memo[0] == mtime_ns:
|
|
return memo[1]
|
|
timeout = None
|
|
if mtime_ns != -1:
|
|
raw, host_block = _ambient_host_block()
|
|
timeout = _resolve_optional_float(*_HostLookup(host_block, raw).vals("timeout", "requestTimeout"))
|
|
_honcho_json_timeout_memo[path_key] = (mtime_ns, timeout)
|
|
return timeout
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _resolve_timeout_from_sources(config: HonchoClientConfig | None) -> float:
|
|
"""Mirror the build path's timeout resolution exactly.
|
|
|
|
Any skew between this and ``_build`` makes the staleness check disagree
|
|
with the built client forever and rebuild it on every call.
|
|
"""
|
|
from plugins.memory.honcho.client import _resolve_optional_float
|
|
|
|
if config is not None:
|
|
timeout = config.timeout
|
|
else:
|
|
timeout = _honcho_json_timeout()
|
|
if timeout is None:
|
|
timeout = _resolve_optional_float(os.environ.get("HONCHO_TIMEOUT"))
|
|
if timeout is None:
|
|
timeout = _config_yaml_timeout()
|
|
return timeout if timeout is not None else _DEFAULT_HTTP_TIMEOUT
|
|
|
|
|
|
def _apply_fresh_oauth_token(config: HonchoClientConfig) -> None:
|
|
"""Refresh a near-expiry OAuth grant and point ``config.api_key`` at it.
|
|
|
|
No-op for static keys or on failure (the first 401 triggers session.py's
|
|
forced rotation). Refreshes against the config's BOUND path: the ambient
|
|
resolver on daemon threads lands on the default profile's file.
|
|
"""
|
|
try:
|
|
from plugins.memory.honcho import oauth
|
|
|
|
token, _ = oauth.ensure_fresh_token(config.bound_config_path(), config.host)
|
|
if token:
|
|
config.api_key = token
|
|
except Exception:
|
|
logger.warning("Honcho OAuth pre-build refresh failed", exc_info=True)
|
|
|
|
|
|
def _refresh_cached_oauth(client: Honcho, config: HonchoClientConfig | None, slot: SingletonSlot | None = None) -> None:
|
|
"""Rotate the cached client's Bearer in place when its OAuth token is stale.
|
|
|
|
If the in-place rotation can't apply (SDK shape change), the slot is reset
|
|
so the next acquisition rebuilds with the fresh token.
|
|
"""
|
|
from plugins.memory.honcho.client import resolve_active_host, resolve_config_path
|
|
|
|
try:
|
|
from plugins.memory.honcho import oauth
|
|
|
|
if config is not None:
|
|
host, path = config.host, config.bound_config_path()
|
|
else:
|
|
host, path = resolve_active_host(), resolve_config_path()
|
|
token, refreshed = oauth.ensure_fresh_token(path, host)
|
|
if refreshed and token and not oauth.apply_token_to_client(client, token) and slot is not None:
|
|
slot.reset()
|
|
except Exception:
|
|
logger.warning("Honcho OAuth cached refresh failed", exc_info=True)
|