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