fix: make Honcho startup fail open
This commit is contained in:
parent
59510d7b44
commit
f24b7ed9d9
2 changed files with 499 additions and 52 deletions
|
|
@ -228,6 +228,9 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
self._session_initialized = False
|
||||
self._lazy_init_kwargs: Optional[dict] = None
|
||||
self._lazy_init_session_id: Optional[str] = None
|
||||
self._init_thread: Optional[threading.Thread] = None
|
||||
self._init_lock = threading.Lock()
|
||||
self._init_error = ""
|
||||
|
||||
# Port #4053: cron guard — when True, plugin is fully inactive
|
||||
self._cron_skipped = False
|
||||
|
|
@ -326,22 +329,24 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
# aiPeer comes from honcho.json (host block or root) only.
|
||||
# SOUL.md is persona content, not identity config.
|
||||
|
||||
# ----- Port #1957: lazy session init for tools-only mode -----
|
||||
self._lazy_init_kwargs = dict(kwargs)
|
||||
self._lazy_init_session_id = session_id
|
||||
self._session_key = self._resolve_session_key(cfg, session_id, **kwargs)
|
||||
|
||||
# Network-backed session creation can block on Honcho service or DB
|
||||
# outages. Startup must fail open for context/hybrid modes, where
|
||||
# Honcho is initialized only to enrich prompts. Tools-only mode has
|
||||
# an explicit contract: init_on_session_start=False stays lazy until
|
||||
# the first tool call, while init_on_session_start=True remains an
|
||||
# eager, ready-on-return initialization path.
|
||||
if self._recall_mode == "tools":
|
||||
if cfg.init_on_session_start:
|
||||
# Eager init even in tools mode (opt-in)
|
||||
self._do_session_init(cfg, session_id, **kwargs)
|
||||
self._ensure_session()
|
||||
return
|
||||
# Defer actual session creation until first tool call
|
||||
self._lazy_init_kwargs = kwargs
|
||||
self._lazy_init_session_id = session_id
|
||||
# Still need a client reference for _ensure_session
|
||||
self._config = cfg
|
||||
logger.debug("Honcho tools-only mode — deferring session init until first tool call")
|
||||
return
|
||||
|
||||
# ----- Eager init (context or hybrid mode) -----
|
||||
self._do_session_init(cfg, session_id, **kwargs)
|
||||
self._start_session_init_background(wait_timeout=0.1)
|
||||
|
||||
except ImportError:
|
||||
logger.debug("honcho-ai package not installed — plugin inactive")
|
||||
|
|
@ -349,6 +354,66 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
logger.warning("Honcho init failed: %s", e)
|
||||
self._manager = None
|
||||
|
||||
def _resolve_session_key(self, cfg, session_id: str, **kwargs) -> str:
|
||||
"""Resolve the Honcho session key without touching the network."""
|
||||
session_title = kwargs.get("session_title")
|
||||
gateway_session_key = kwargs.get("gateway_session_key")
|
||||
return (
|
||||
cfg.resolve_session_name(
|
||||
session_title=session_title,
|
||||
session_id=session_id,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
or session_id
|
||||
or "hermes-default"
|
||||
)
|
||||
|
||||
def _start_session_init_background(self, *, wait_timeout: float = 0.0) -> None:
|
||||
"""Start Honcho session initialization in a daemon thread.
|
||||
|
||||
This keeps Hermes CLI/gateway startup responsive when Honcho is down,
|
||||
slow, or its database is unhealthy. The thread may still take the SDK
|
||||
timeout path, but it cannot block agent construction or first prompt
|
||||
assembly. ``wait_timeout`` lets fast/mock initializations finish before
|
||||
returning while still failing open for slow backends.
|
||||
"""
|
||||
if self._cron_skipped or self._session_initialized:
|
||||
return
|
||||
if not self._config or self._lazy_init_kwargs is None:
|
||||
return
|
||||
|
||||
with self._init_lock:
|
||||
if self._cron_skipped or self._session_initialized:
|
||||
return
|
||||
if self._init_thread and self._init_thread.is_alive():
|
||||
return
|
||||
if not self._config or self._lazy_init_kwargs is None:
|
||||
return
|
||||
|
||||
cfg = self._config
|
||||
init_kwargs = dict(self._lazy_init_kwargs)
|
||||
init_session_id = self._lazy_init_session_id or "hermes-default"
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
self._do_session_init(cfg, init_session_id, **init_kwargs)
|
||||
self._lazy_init_kwargs = None
|
||||
self._lazy_init_session_id = None
|
||||
self._init_error = ""
|
||||
except Exception as e:
|
||||
self._init_error = str(e)
|
||||
self._manager = None
|
||||
logger.warning("Honcho background session init failed: %s", e)
|
||||
|
||||
self._init_thread = threading.Thread(
|
||||
target=_run,
|
||||
daemon=True,
|
||||
name="honcho-session-init",
|
||||
)
|
||||
self._init_thread.start()
|
||||
if wait_timeout > 0:
|
||||
self._init_thread.join(timeout=wait_timeout)
|
||||
|
||||
def _do_session_init(self, cfg, session_id: str, **kwargs) -> None:
|
||||
"""Shared session initialization logic for both eager and lazy paths."""
|
||||
from plugins.memory.honcho.client import get_honcho_client
|
||||
|
|
@ -364,22 +429,15 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
)
|
||||
|
||||
# ----- B3: resolve_session_name -----
|
||||
session_title = kwargs.get("session_title")
|
||||
gateway_session_key = kwargs.get("gateway_session_key")
|
||||
self._session_key = (
|
||||
cfg.resolve_session_name(
|
||||
session_title=session_title,
|
||||
session_id=session_id,
|
||||
gateway_session_key=gateway_session_key,
|
||||
)
|
||||
or session_id
|
||||
or "hermes-default"
|
||||
)
|
||||
self._session_key = self._resolve_session_key(cfg, session_id, **kwargs)
|
||||
logger.debug("Honcho session key resolved: %s", self._session_key)
|
||||
|
||||
# Create session eagerly
|
||||
# Create the remote session before running startup-only migration and
|
||||
# prewarm work. Do not mark the provider ready until this method's
|
||||
# synchronous setup has finished; background startup sets _manager before
|
||||
# get_or_create()/migration/prewarm are complete, and lifecycle hooks must
|
||||
# not treat that partially initialized state as usable.
|
||||
session = self._manager.get_or_create(self._session_key)
|
||||
self._session_initialized = True
|
||||
|
||||
# ----- B6: Memory file migration (one-time, for new sessions) -----
|
||||
# Skip under per-session strategy: every Hermes run creates a fresh
|
||||
|
|
@ -434,12 +492,15 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
self._dialectic_empty_streak += 1
|
||||
|
||||
self._prefetch_thread_started_at = time.monotonic()
|
||||
self._prefetch_thread = threading.Thread(
|
||||
prewarm_thread = threading.Thread(
|
||||
target=_prewarm_dialectic, daemon=True, name="honcho-prewarm-dialectic"
|
||||
)
|
||||
self._prefetch_thread.start()
|
||||
prewarm_thread.start()
|
||||
self._prefetch_thread = prewarm_thread
|
||||
logger.debug("Honcho pre-warm started for session: %s", self._session_key)
|
||||
|
||||
self._session_initialized = True
|
||||
|
||||
def _ensure_session(self) -> bool:
|
||||
"""Lazily initialize the Honcho session (for tools-only mode).
|
||||
|
||||
|
|
@ -449,7 +510,9 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
return True
|
||||
if self._cron_skipped:
|
||||
return False
|
||||
if not self._config or not self._lazy_init_kwargs:
|
||||
if self._init_thread and self._init_thread.is_alive():
|
||||
return False
|
||||
if not self._config or self._lazy_init_kwargs is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
|
|
@ -463,9 +526,26 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
self._lazy_init_session_id = None
|
||||
return self._manager is not None
|
||||
except Exception as e:
|
||||
self._manager = None
|
||||
self._session_initialized = False
|
||||
logger.warning("Honcho lazy session init failed: %s", e)
|
||||
return False
|
||||
|
||||
def _session_ready(self) -> bool:
|
||||
"""Return whether a manager/session key can be used safely.
|
||||
|
||||
Background initialization sets ``_manager`` before the blocking
|
||||
get-or-create call completes, so ``_session_initialized`` guards real
|
||||
async startup. Tests and legacy direct construction may inject a ready
|
||||
manager/session key without setting that flag; allow that only when no
|
||||
init thread is currently in flight.
|
||||
"""
|
||||
if not self._manager or not self._session_key:
|
||||
return False
|
||||
if self._session_initialized:
|
||||
return True
|
||||
return not (self._init_thread and self._init_thread.is_alive())
|
||||
|
||||
def _format_first_turn_context(self, ctx: dict) -> str:
|
||||
"""Format the prefetch context dict into a readable system prompt block."""
|
||||
parts = []
|
||||
|
|
@ -505,14 +585,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
if self._cron_skipped:
|
||||
return ""
|
||||
if not self._manager or not self._session_key:
|
||||
# tools-only mode without session yet still returns a minimal block
|
||||
if self._recall_mode == "tools" and self._config:
|
||||
return (
|
||||
"# Honcho Memory\n"
|
||||
"Active (tools-only mode). Use honcho_profile, honcho_search, "
|
||||
"honcho_reasoning, honcho_context, and honcho_conclude tools to access user memory."
|
||||
)
|
||||
return ""
|
||||
if not self._config:
|
||||
return ""
|
||||
|
||||
# ----- B1: adapt text based on recall_mode -----
|
||||
if self._recall_mode == "context":
|
||||
|
|
@ -563,6 +637,10 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
if self._recall_mode == "tools":
|
||||
return ""
|
||||
|
||||
if not self._session_ready():
|
||||
self._start_session_init_background()
|
||||
return ""
|
||||
|
||||
# B5: injection_frequency — if "first-turn" and past first turn, return empty.
|
||||
# _turn_count is 1-indexed (first user message = 1), so > 1 means "past first".
|
||||
if self._injection_frequency == "first-turn" and self._turn_count > 1:
|
||||
|
|
@ -575,18 +653,17 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
parts = []
|
||||
|
||||
# ----- Layer 1: Base context (representation + card) -----
|
||||
# On first call, fetch synchronously so turn 1 isn't empty.
|
||||
# After that, serve from cache and refresh in background on cadence.
|
||||
# First fetch is asynchronous: a slow Honcho backend must not block the
|
||||
# first response. Serve empty context now and consume the background
|
||||
# result on a later turn.
|
||||
with self._base_context_lock:
|
||||
if self._base_context_cache is None:
|
||||
# First call — synchronous fetch
|
||||
self._base_context_cache = ""
|
||||
self._last_context_turn = self._turn_count
|
||||
try:
|
||||
ctx = self._manager.get_prefetch_context(self._session_key)
|
||||
self._base_context_cache = self._format_first_turn_context(ctx) if ctx else ""
|
||||
self._last_context_turn = self._turn_count
|
||||
self._manager.prefetch_context(self._session_key, query or None)
|
||||
except Exception as e:
|
||||
logger.debug("Honcho base context fetch failed: %s", e)
|
||||
self._base_context_cache = ""
|
||||
logger.debug("Honcho base context prefetch failed: %s", e)
|
||||
base_context = self._base_context_cache
|
||||
|
||||
# Check if background context prefetch has a fresher result
|
||||
|
|
@ -641,10 +718,11 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
self._dialectic_empty_streak += 1
|
||||
|
||||
self._prefetch_thread_started_at = time.monotonic()
|
||||
self._prefetch_thread = threading.Thread(
|
||||
first_turn_thread = threading.Thread(
|
||||
target=_run_first_turn, daemon=True, name="honcho-prefetch-first"
|
||||
)
|
||||
self._prefetch_thread.start()
|
||||
first_turn_thread.start()
|
||||
self._prefetch_thread = first_turn_thread
|
||||
self._prefetch_thread.join(timeout=_first_turn_timeout)
|
||||
if self._prefetch_thread.is_alive():
|
||||
logger.debug(
|
||||
|
|
@ -709,13 +787,14 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
"""
|
||||
if self._cron_skipped:
|
||||
return
|
||||
if not self._manager or not self._session_key or not query:
|
||||
return
|
||||
|
||||
# B1: tools-only mode — no prefetch
|
||||
if self._recall_mode == "tools":
|
||||
return
|
||||
|
||||
if not self._session_ready() or not query:
|
||||
self._start_session_init_background()
|
||||
return
|
||||
|
||||
# Trivial prompts don't warrant either a context refresh or a dialectic call.
|
||||
if self._is_trivial_prompt(query):
|
||||
return
|
||||
|
|
@ -769,10 +848,11 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
self._dialectic_empty_streak += 1
|
||||
|
||||
self._prefetch_thread_started_at = time.monotonic()
|
||||
self._prefetch_thread = threading.Thread(
|
||||
prefetch_thread = threading.Thread(
|
||||
target=_run, daemon=True, name="honcho-prefetch"
|
||||
)
|
||||
self._prefetch_thread.start()
|
||||
prefetch_thread.start()
|
||||
self._prefetch_thread = prefetch_thread
|
||||
|
||||
# ----- Dialectic depth: multi-pass .chat() with cold/warm prompts -----
|
||||
|
||||
|
|
@ -1126,7 +1206,10 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
"""
|
||||
if self._cron_skipped:
|
||||
return
|
||||
if not self._manager or not self._session_key:
|
||||
if self._recall_mode == "tools" and not self._session_ready():
|
||||
return
|
||||
if not self._session_ready():
|
||||
self._start_session_init_background()
|
||||
return
|
||||
|
||||
msg_limit = self._config.message_max_chars if self._config else 25000
|
||||
|
|
@ -1169,7 +1252,10 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
return
|
||||
if self._cron_skipped:
|
||||
return
|
||||
if not self._manager or not self._session_key:
|
||||
if self._recall_mode == "tools" and not self._session_ready():
|
||||
return
|
||||
if not self._session_ready():
|
||||
self._start_session_init_background()
|
||||
return
|
||||
|
||||
def _write():
|
||||
|
|
@ -1187,6 +1273,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
return
|
||||
if not self._manager:
|
||||
return
|
||||
if not self._session_initialized and self._init_thread and self._init_thread.is_alive():
|
||||
return
|
||||
# Wait for pending sync
|
||||
if self._sync_thread and self._sync_thread.is_alive():
|
||||
self._sync_thread.join(timeout=10.0)
|
||||
|
|
@ -1213,6 +1301,8 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
|
||||
# Port #1957: ensure session is initialized for tools-only mode
|
||||
if not self._session_initialized:
|
||||
if self._init_thread and self._init_thread.is_alive():
|
||||
return tool_error("Honcho session is still initializing; try again shortly.")
|
||||
if not self._ensure_session():
|
||||
return tool_error("Honcho session could not be initialized.")
|
||||
|
||||
|
|
@ -1313,7 +1403,7 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
if t and t.is_alive():
|
||||
t.join(timeout=5.0)
|
||||
# Flush any remaining messages
|
||||
if self._manager:
|
||||
if self._manager and not (self._init_thread and self._init_thread.is_alive() and not self._session_initialized):
|
||||
try:
|
||||
self._manager.flush_all()
|
||||
except Exception:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue