From 63288f1d805a2b3ea8e27bc845065b67c1e3f320 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): stop dropping dialectic results on trivial turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: Honcho logs show a dialectic answer was generated, but Hermes never injects it — intermittently. Root cause: the dialectic supplement that queue_prefetch() fires at the end of turn N is stored pending (fired_at=N) for consumption by turn N+1's prefetch(). But prefetch()'s trivial-prompt guard returned early BEFORE the consumption block. So when turn N+1's prompt was trivial ('ok', 'yes', 'continue', a slash command), the ready result was never consumed, and a few turns later the stale-discard guard dropped it. Generated by Honcho, never seen by the model. The dependence on 'is the consuming turn trivial?' is why the loss looked random. Fix: trivial turns now consume and inject a ready, non-stale pending result while still spending no new work (no base-context fetch, no new dialectic fire). A trivial ack shouldn't generate context, but it shouldn't destroy an answer already computed for that exact turn. Extracted the pop+stale-check into a shared _consume_pending_dialectic() used by both the trivial path and the normal path so they age-check identically. Preserved (covered by new tests): genuinely stale results are still discarded on trivial turns; trivial turns still fire no new work; a trivial turn with nothing pending still injects nothing. Adds regression tests for inject-on-trivial and discard-stale-on-trivial. --- plugins/memory/honcho/__init__.py | 56 +++++++++++++++++++++-------- tests/honcho_plugin/test_session.py | 43 ++++++++++++++++++++++ 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index b36348d16cec..eb9dcf3bc669 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -692,9 +692,18 @@ class HonchoMemoryProvider(MemoryProvider): if self._injection_frequency == "first-turn" and self._turn_count > 1: return "" - # Trivial prompts ("ok", "yes", slash commands) carry no semantic signal. + # Trivial prompts ("ok", "yes", slash commands) carry no semantic + # signal, so we don't fetch base context or fire new dialectic work for + # them. But a dialectic result fired at the END of the previous turn was + # primed for THIS turn — blindly returning here used to strand it + # (consumption happens further down), after which the stale-discard + # guard silently dropped a successfully-generated answer. Instead, + # drain any ready non-stale pending result and inject just that: + # trivial turns still spend no NEW work, but they no longer throw + # away work already paid for. if self._is_trivial_prompt(query): - return "" + ready = self._consume_pending_dialectic() + return self._truncate_to_budget(ready) if ready else "" parts = [] @@ -842,6 +851,35 @@ class HonchoMemoryProvider(MemoryProvider): if (not _did_first_turn_wait and self._prefetch_thread and self._prefetch_thread.is_alive()): self._prefetch_thread.join(timeout=3.0) + + # Drain the pending result (applies the stale-discard guard). Shared + # with the trivial-prompt path above via _consume_pending_dialectic so + # both routes pop + age-check identically. + dialectic_result = self._consume_pending_dialectic() + + if dialectic_result and dialectic_result.strip(): + parts.append(dialectic_result) + + if not parts: + return "" + + result = "\n\n".join(parts) + + # ----- Port #3265: token budget enforcement ----- + result = self._truncate_to_budget(result) + + return result + + def _consume_pending_dialectic(self) -> str: + """Pop any pending dialectic result, applying the stale-discard guard. + + Drains _prefetch_result under the lock and returns it unless it is + stale (fired more than cadence × multiplier turns ago), in which case + it is dropped and "" is returned. Does NOT wait on an in-flight thread + — callers that want to give a near-finished thread a moment must join + before calling. Idempotent: a second call returns "" until a new + result is primed. + """ with self._prefetch_lock: dialectic_result = self._prefetch_result fired_at = self._prefetch_result_fired_at @@ -858,20 +896,8 @@ class HonchoMemoryProvider(MemoryProvider): "Honcho pending dialectic discarded as stale: fired_at=%d, " "turn=%d, limit=%d", fired_at, self._turn_count, stale_limit, ) - dialectic_result = "" - - if dialectic_result and dialectic_result.strip(): - parts.append(dialectic_result) - - if not parts: return "" - - result = "\n\n".join(parts) - - # ----- Port #3265: token budget enforcement ----- - result = self._truncate_to_budget(result) - - return result + return dialectic_result if (dialectic_result and dialectic_result.strip()) else "" def _truncate_to_budget(self, text: str) -> str: """Truncate text to fit within context_tokens budget if set.""" diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index 6b6cbdb49c8b..19b1e314b454 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -1225,6 +1225,49 @@ class TestTrivialPromptHeuristic: assert provider._manager.prefetch_context.call_count == 0 assert provider._manager.dialectic_query.call_count == 0 + def test_trivial_prompt_injects_ready_pending_dialectic(self): + """Regression: a dialectic result fired at the end of the prior turn and + primed for THIS turn must still be injected when this turn's prompt is + trivial — not stranded by the trivial early-return and later silently + discarded as stale. Option A: trivial turns consume + inject a ready, + non-stale pending result (but spend no new work).""" + provider = self._make_provider() + provider._session_key = "test" + provider._base_context_cache = "" # isolate the supplement path + provider._dialectic_cadence = 4 + provider._turn_count = 2 + # Simulate: queue_prefetch fired the dialectic at end of turn 1. + provider._last_dialectic_turn = 1 + with provider._prefetch_lock: + provider._prefetch_result = "PENDING_DIALECTIC" + provider._prefetch_result_fired_at = 1 + + injected = provider.prefetch("ok") + + assert "PENDING_DIALECTIC" in injected + # And it was consumed, not left to go stale. + with provider._prefetch_lock: + assert provider._prefetch_result == "" + + def test_trivial_prompt_discards_stale_pending_dialectic(self): + """A pending result older than cadence × multiplier must still be + discarded on a trivial turn — the fix must not resurrect stale content.""" + provider = self._make_provider() + provider._session_key = "test" + provider._base_context_cache = "" + provider._dialectic_cadence = 4 # stale_limit = 4 * 2 = 8 + provider._last_dialectic_turn = 1 + provider._turn_count = 1 + 4 * provider._STALE_RESULT_MULTIPLIER + 1 # 10 → stale + with provider._prefetch_lock: + provider._prefetch_result = "STALE_DIALECTIC" + provider._prefetch_result_fired_at = 1 + + injected = provider.prefetch("ok") + + assert injected == "" + with provider._prefetch_lock: + assert provider._prefetch_result == "" + class TestDialecticCadenceAdvancesOnSuccess: """Cadence tracker advances only when the dialectic call returns a