fix(honcho): stop dropping dialectic results on trivial turns

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.
This commit is contained in:
k4z4n0v4 2026-06-25 10:10:00 +04:00 committed by Teknium
parent bef9eea3e6
commit 63288f1d80
2 changed files with 84 additions and 15 deletions

View file

@ -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."""

View file

@ -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