From bef9eea3e6c482742328567110bd662403b25a46 Mon Sep 17 00:00:00 2001 From: k4z4n0v4 <46030560+k4z4n0v4@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:10:00 +0400 Subject: [PATCH] fix(honcho): inject base context on the first message of a session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A brand-new session injected no Honcho context on the user's first message — the peer card/representation only showed up from turn 2 onward. The base-context fetch was fired asynchronously and popped in the same synchronous pass, so it always lost the race on turn 1 (a background thread can't finish inside one pass), leaving the first response with zero recalled context. Fetch the base layer (representation + card + summary) synchronously with a bounded timeout on turn 1 so the peer card is injected immediately; subsequent turns still consume the background-refreshed result primed by queue_prefetch(). The wait is bounded by _FIRST_TURN_BASE_TIMEOUT and tightened further by a small configured request timeout (fail-fast deployments / tests). Two related first-turn/dialectic reliability fixes ride along: - first-turn dialectic no longer double-fires: if a prewarm .chat() thread is already in flight from session init, turn 1 waits briefly for it instead of firing a second (duplicate) call that also blocked the first response. The first-turn wait is decoupled from a large host timeout (a 60s host timeout must not block the first response for 60s) via _FIRST_TURN_DIALECTIC_CAP, while still honoring a tight configured timeout. - empty-pass propagation guard in multi-pass dialectic: at depth > 1 each pass feeds the prior pass's output into the next prompt. If a pass returned empty (e.g. a reasoning model that spent its whole budget thinking), the next prompt carried a blank assessment (the "empty spot" seen in Honcho request logs). Now only non-empty prior results feed dependent passes; if all priors are empty, re-issue the base prompt instead of referencing nothing. --- plugins/memory/honcho/__init__.py | 196 +++++++++++++++++++++++------- 1 file changed, 151 insertions(+), 45 deletions(-) diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index ebf8ecc22482..b36348d16cec 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -673,8 +673,19 @@ class HonchoMemoryProvider(MemoryProvider): return "" if not self._session_ready(): + # Session init runs in a background daemon thread (kept off the + # startup path so a slow/unreachable Honcho can't block agent + # construction). On turn 1 of a fresh process that thread may not + # have finished yet. Rather than return empty immediately — which + # denied turn-1 context entirely whenever init crossed the ~100ms + # spawn window — wait a bounded time for it to complete so the + # peer card can still be injected on the first message. self._start_session_init_background() - return "" + _init_thread = self._init_thread + if _init_thread is not None: + _init_thread.join(timeout=self._FIRST_TURN_BASE_TIMEOUT) + if not self._session_ready(): + 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". @@ -688,21 +699,61 @@ class HonchoMemoryProvider(MemoryProvider): parts = [] # ----- Layer 1: Base context (representation + card) ----- - # 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. + # Base context is pure retrieval (no LLM): representation + peer card + + # session summary. On the FIRST fetch (cache is None, i.e. turn 1 of a + # session) we fetch it SYNCHRONOUSLY with a short bound so the peer card + # is injected immediately. Firing it async and popping in the same call + # always lost the race on turn 1 (background thread can't finish inside + # one synchronous pass), which is why new sessions saw zero context + # until turn 2. Subsequent turns consume the background-refreshed result + # primed by queue_prefetch() at the end of the previous turn. with self._base_context_lock: - if self._base_context_cache is None: + _first_base_fetch = self._base_context_cache is None + if _first_base_fetch: self._base_context_cache = "" self._last_context_turn = self._turn_count - try: - self._manager.prefetch_context(self._session_key, query or None) - except Exception as e: - logger.debug("Honcho base context prefetch failed: %s", e) base_context = self._base_context_cache - # Check if background context prefetch has a fresher result - if self._manager: + if _first_base_fetch and self._manager: + # Synchronous, bounded fetch so turn 1 gets the peer card now. + _ctx_holder: dict[str, dict] = {} + + def _fetch_base() -> None: + try: + _ctx_holder["ctx"] = self._manager.get_prefetch_context( + self._session_key, query or None + ) or {} + except Exception as e: + logger.debug("Honcho first-turn base context failed: %s", e) + + _bt = threading.Thread( + target=_fetch_base, daemon=True, name="honcho-base-first" + ) + _bt.start() + # Bound the synchronous wait by the smaller of the base timeout and + # any configured request timeout, so a deliberately tight + # config.timeout (fail-fast deployments, tests) is still honored. + _base_wait = self._FIRST_TURN_BASE_TIMEOUT + if self._config and self._config.timeout: + _base_wait = min(_base_wait, self._config.timeout) + _bt.join(timeout=_base_wait) + _ctx = _ctx_holder.get("ctx") + if _ctx: + formatted = self._format_first_turn_context(_ctx) + if formatted: + with self._base_context_lock: + self._base_context_cache = formatted + base_context = formatted + elif _bt.is_alive(): + logger.debug( + "Honcho first-turn base context still running after %.1fs — " + "will surface on next turn", self._FIRST_TURN_BASE_TIMEOUT, + ) + + # Check if a background context prefetch (primed last turn) has a + # fresher result. On turn 1 this is normally empty; turn 2+ consumes + # the result queued by queue_prefetch(). + if not _first_base_fetch and self._manager: fresh_ctx = self._manager.pop_context_result(self._session_key) if fresh_ctx: formatted = self._format_first_turn_context(fresh_ctx) @@ -728,45 +779,68 @@ class HonchoMemoryProvider(MemoryProvider): if _prewarm_landed and self._last_dialectic_turn == -999: self._last_dialectic_turn = self._turn_count + _did_first_turn_wait = False if self._last_dialectic_turn == -999 and query: - _first_turn_timeout = ( - self._config.timeout if self._config and self._config.timeout else 8.0 - ) - _fired_at = self._turn_count + _did_first_turn_wait = True + # A dialectic prewarm thread is fired at session init with an + # equivalent query. If it's still running, do NOT fire a second + # .chat() here — that was duplicate work that also blocked the + # first response. Briefly wait for the in-flight prewarm to land; + # if it doesn't, let it surface on a later turn via _prefetch_result. + # First-turn dialectic wait. Decoupled from a LARGE config.timeout + # (a 60s host timeout must not block the first response for 60s), + # but still bounded by a TIGHT config.timeout when one is set + # (fail-fast deployments / tests). See _FIRST_TURN_DIALECTIC_CAP. + _dia_wait = self._FIRST_TURN_DIALECTIC_CAP + if self._config and self._config.timeout: + _dia_wait = min(_dia_wait, self._config.timeout) + if self._thread_is_live(): + _live = self._prefetch_thread + if _live is not None: + _live.join(timeout=_dia_wait) + else: + _first_turn_timeout = _dia_wait + _fired_at = self._turn_count - def _run_first_turn() -> None: - try: - r = self._run_dialectic_depth(query) - except Exception as exc: - logger.debug("Honcho first-turn dialectic failed: %s", exc) - self._dialectic_empty_streak += 1 - return - if r and r.strip(): - with self._prefetch_lock: - self._prefetch_result = r - self._prefetch_result_fired_at = _fired_at - # Advance cadence only on a non-empty result so the next - # turn retries when the call returned nothing. - self._last_dialectic_turn = _fired_at - self._dialectic_empty_streak = 0 - else: - self._dialectic_empty_streak += 1 + def _run_first_turn() -> None: + try: + r = self._run_dialectic_depth(query) + except Exception as exc: + logger.debug("Honcho first-turn dialectic failed: %s", exc) + self._dialectic_empty_streak += 1 + return + if r and r.strip(): + with self._prefetch_lock: + self._prefetch_result = r + self._prefetch_result_fired_at = _fired_at + # Advance cadence only on a non-empty result so the next + # turn retries when the call returned nothing. + self._last_dialectic_turn = _fired_at + self._dialectic_empty_streak = 0 + else: + self._dialectic_empty_streak += 1 - self._prefetch_thread_started_at = time.monotonic() - first_turn_thread = threading.Thread( - target=_run_first_turn, daemon=True, name="honcho-prefetch-first" - ) - first_turn_thread.start() - self._prefetch_thread = first_turn_thread - self._prefetch_thread.join(timeout=_first_turn_timeout) - if self._prefetch_thread.is_alive(): + self._prefetch_thread_started_at = time.monotonic() + first_turn_thread = threading.Thread( + target=_run_first_turn, daemon=True, name="honcho-prefetch-first" + ) + first_turn_thread.start() + self._prefetch_thread = first_turn_thread + self._prefetch_thread.join(timeout=_first_turn_timeout) + if self._prefetch_thread and self._prefetch_thread.is_alive(): logger.debug( "Honcho first-turn dialectic still running after %.1fs — " "will surface on next turn", - _first_turn_timeout, + self._FIRST_TURN_DIALECTIC_CAP, ) - if self._prefetch_thread and self._prefetch_thread.is_alive(): + # Turn 2+ consumes the result primed by queue_prefetch() last turn — + # give a near-finished thread a brief moment. On turn 1 we already + # waited above (_did_first_turn_wait); waiting again here just + # re-blocks on the same slow in-flight dialectic with no chance of it + # landing, so skip the redundant join. + if (not _did_first_turn_wait and self._prefetch_thread + and self._prefetch_thread.is_alive()): self._prefetch_thread.join(timeout=3.0) with self._prefetch_lock: dialectic_result = self._prefetch_result @@ -922,6 +996,22 @@ class HonchoMemoryProvider(MemoryProvider): # Cap on the empty-streak backoff so a persistently silent backend # eventually settles on a ceiling instead of unbounded widening. _BACKOFF_MAX = 8 + # First-turn base-context fetch bound. Base context (representation + + # peer card) is pure retrieval with no LLM call (~400-600ms locally), so + # turn 1 can afford to wait for it synchronously and inject the peer card + # immediately instead of returning an empty block that only fills from + # turn 2. A short bound keeps a slow/unreachable backend from blocking the + # first response. + _FIRST_TURN_BASE_TIMEOUT = 3.0 + # First-turn dialectic grace. The dialectic is the slow LLM path + # (multi-pass .chat(), often 20s+ at depth 3) and must stay decoupled from + # config.timeout: once the host-block timeout is honored (e.g. 60s), + # reusing it here would block the first response for the full request + # timeout. Turn 1's value is the base context (peer card), fetched + # synchronously above; the dialectic only needs an opportunistic grace to + # catch a near-finished prewarm. If it doesn't land in this window it + # surfaces on turn 2 via _prefetch_result — no benefit to blocking longer. + _FIRST_TURN_DIALECTIC_CAP = 2.0 def _thread_is_live(self) -> bool: """Thread-alive guard that treats threads older than the stale @@ -1077,15 +1167,31 @@ class HonchoMemoryProvider(MemoryProvider): results: list[str] = [] for i in range(self._dialectic_depth): + # Only non-empty prior results are usable context for dependent + # passes. A pass can return "" (e.g. a reasoning model that spends + # its whole token budget thinking and emits no content). Feeding + # that blank forward produced prompts like "Given this initial + # assessment:\n\n\n\nWhat gaps remain..." with an empty body — the + # empty-spot symptom seen in Honcho request logs. + prior_results = [r for r in results if r and r.strip()] if i == 0: - prompt = self._build_dialectic_prompt(0, results, is_cold) + prompt = self._build_dialectic_prompt(0, prior_results, is_cold) else: # Skip further passes if prior pass delivered strong signal - if results and self._signal_sufficient(results[-1]): + if prior_results and self._signal_sufficient(prior_results[-1]): logger.debug("Honcho dialectic depth %d: pass %d skipped, prior signal sufficient", self._dialectic_depth, i) break - prompt = self._build_dialectic_prompt(i, results, is_cold) + if not prior_results: + # Every prior pass returned empty — a dependent prompt would + # carry a blank assessment. Re-issue the pass-0 base prompt + # instead so this pass starts fresh rather than referencing + # nothing. + logger.debug("Honcho dialectic depth %d: pass %d has no non-empty prior — " + "falling back to base prompt", self._dialectic_depth, i) + prompt = self._build_dialectic_prompt(0, prior_results, is_cold) + else: + prompt = self._build_dialectic_prompt(i, prior_results, is_cold) level = self._resolve_pass_level(i, query=query) logger.debug("Honcho dialectic depth %d: pass %d, level=%s, cold=%s",