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.