From 3e4e3db66d048efbcb84a8a15d22b8e22c1437ef Mon Sep 17 00:00:00 2001 From: k4z4n0v4 <46030560+k4z4n0v4@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:09:10 +0400 Subject: [PATCH] fix(honcho): don't let first-turn injection suppress dialectic injectionFrequency='first-turn' returned empty for the entire prefetch_context() method on turns 2+, which blocked the dialectic supplement from being consumed and injected. The dialectic has its own cadence (dialecticCadence) and must continue to fire and inject independently of the base context layer. Now first-turn mode gates only Layer 1 (base context: representation + card), letting Layer 2 (dialectic supplement) flow through its normal consumption path on every turn. Also fixes all remaining tests that passed dialecticCadence via cfg_extra={'raw': {...}} to use the typed dialectic_cadence field. --- plugins/memory/honcho/__init__.py | 136 +++++++++++++++------------- tests/honcho_plugin/test_session.py | 14 +-- 2 files changed, 80 insertions(+), 70 deletions(-) diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index 298a117e6823..05670adf6cb7 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -681,10 +681,12 @@ class HonchoMemoryProvider(MemoryProvider): 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". - if self._injection_frequency == "first-turn" and self._turn_count > 1: - return "" + # B5: injection_frequency — if "first-turn" and past first turn, skip + # the base context layer (static representation + card). The dialectic + # supplement has its own cadence and must still be consumed below. + _skip_base = ( + self._injection_frequency == "first-turn" and self._turn_count > 1 + ) # Trivial prompts ("ok", "yes", slash commands) carry no semantic # signal, so we don't fetch base context or fire new dialectic work for @@ -702,71 +704,79 @@ class HonchoMemoryProvider(MemoryProvider): parts = [] # ----- Layer 1: Base context (representation + card) ----- - # 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: - _first_base_fetch = self._base_context_cache is None - if _first_base_fetch: - self._base_context_cache = "" - self._last_context_turn = self._turn_count - base_context = self._base_context_cache + # Skipped when injectionFrequency is "first-turn" past turn 1 — the + # representation + card are static within a session and re-injecting + # them every turn is pure token waste. + if _skip_base: + # Still drain any pending dialectic result below; just don't + # fetch or inject the base context layer. + pass + else: + # 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: + _first_base_fetch = self._base_context_cache is None + if _first_base_fetch: + self._base_context_cache = "" + self._last_context_turn = self._turn_count + base_context = self._base_context_cache - if _first_base_fetch and self._manager: - # Synchronous, bounded fetch so turn 1 gets the peer card now. - _ctx_holder: dict[str, dict] = {} + 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) + 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, + _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) - if formatted: - with self._base_context_lock: - self._base_context_cache = formatted - base_context = formatted + # 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) + if formatted: + with self._base_context_lock: + self._base_context_cache = formatted + base_context = formatted - if base_context: - parts.append(base_context) + if base_context: + parts.append(base_context) # ----- Layer 2: Dialectic supplement ----- # On the very first turn, no queue_prefetch() has run yet so the diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index d47a908a9f52..cb73cc0da5e7 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -1489,7 +1489,7 @@ class TestDialecticLiveness: def test_stale_pending_result_is_discarded_on_read(self): """A pending dialectic result from many turns ago is discarded instead of injected against a fresh conversational pivot.""" - p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 2}}) + p = self._make_provider(cfg_extra={"dialectic_cadence": 2}) p._session_key = "test" p._base_context_cache = "base ctx" with p._prefetch_lock: @@ -1508,7 +1508,7 @@ class TestDialecticLiveness: def test_fresh_pending_result_is_kept(self): """A pending result within the staleness window is injected normally.""" - p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 3}}) + p = self._make_provider(cfg_extra={"dialectic_cadence": 3}) p._session_key = "test" p._base_context_cache = "" with p._prefetch_lock: @@ -1522,14 +1522,14 @@ class TestDialecticLiveness: def test_empty_streak_widens_effective_cadence(self): """After N empty returns, the gate waits cadence + N turns.""" - p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 1}}) + p = self._make_provider(cfg_extra={"dialectic_cadence": 1}) p._dialectic_empty_streak = 3 # cadence=1, streak=3 → effective = 4 assert p._effective_cadence() == 4 def test_backoff_is_capped(self): """Effective cadence is capped at cadence × _BACKOFF_MAX.""" - p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 2}}) + p = self._make_provider(cfg_extra={"dialectic_cadence": 2}) p._dialectic_empty_streak = 100 # cadence=2, ceiling = 2 × 8 = 16 assert p._effective_cadence() == 16 @@ -1537,7 +1537,7 @@ class TestDialecticLiveness: def test_success_resets_empty_streak(self): """A non-empty result zeroes the streak so healthy operation restores the base cadence immediately.""" - p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 1}}) + p = self._make_provider(cfg_extra={"dialectic_cadence": 1}) p._session_key = "test" p._dialectic_empty_streak = 5 p._turn_count = 10 @@ -1551,7 +1551,7 @@ class TestDialecticLiveness: assert p._last_dialectic_turn == 10 def test_empty_result_increments_streak(self): - p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 1}}) + p = self._make_provider(cfg_extra={"dialectic_cadence": 1}) p._session_key = "test" p._turn_count = 5 p._last_dialectic_turn = 0 @@ -1638,7 +1638,7 @@ class TestDialecticLifecycleSmoke: """ from unittest.mock import patch, MagicMock provider, mgr, cfg = self._make_provider( - cfg_extra={"raw": {"dialecticCadence": 3}} + cfg_extra={"dialectic_cadence": 3} ) # Program the dialectic responses in the exact order they'll be requested.