mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
parent
bef9eea3e6
commit
63288f1d80
2 changed files with 84 additions and 15 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue