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