mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fix(honcho): don't let first-turn injection suppress dialectic
injectionFrequency='first-turn' returned empty for the entire
prefetch_context() method on turns 2+, which blocked the dialectic
supplement from being consumed and injected. The dialectic has its
own cadence (dialecticCadence) and must continue to fire and inject
independently of the base context layer.
Now first-turn mode gates only Layer 1 (base context: representation
+ card), letting Layer 2 (dialectic supplement) flow through its
normal consumption path on every turn.
Also fixes all remaining tests that passed dialecticCadence via
cfg_extra={'raw': {...}} to use the typed dialectic_cadence field.
This commit is contained in:
parent
d2b6c21a3c
commit
3e4e3db66d
2 changed files with 80 additions and 70 deletions
|
|
@ -681,10 +681,12 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
if not self._session_ready():
|
||||
return ""
|
||||
|
||||
# B5: injection_frequency — if "first-turn" and past first turn, return empty.
|
||||
# _turn_count is 1-indexed (first user message = 1), so > 1 means "past first".
|
||||
if self._injection_frequency == "first-turn" and self._turn_count > 1:
|
||||
return ""
|
||||
# B5: injection_frequency — if "first-turn" and past first turn, skip
|
||||
# the base context layer (static representation + card). The dialectic
|
||||
# supplement has its own cadence and must still be consumed below.
|
||||
_skip_base = (
|
||||
self._injection_frequency == "first-turn" and self._turn_count > 1
|
||||
)
|
||||
|
||||
# Trivial prompts ("ok", "yes", slash commands) carry no semantic
|
||||
# signal, so we don't fetch base context or fire new dialectic work for
|
||||
|
|
@ -702,71 +704,79 @@ class HonchoMemoryProvider(MemoryProvider):
|
|||
parts = []
|
||||
|
||||
# ----- Layer 1: Base context (representation + card) -----
|
||||
# Base context is pure retrieval (no LLM): representation + peer card +
|
||||
# session summary. On the FIRST fetch (cache is None, i.e. turn 1 of a
|
||||
# session) we fetch it SYNCHRONOUSLY with a short bound so the peer card
|
||||
# is injected immediately. Firing it async and popping in the same call
|
||||
# always lost the race on turn 1 (background thread can't finish inside
|
||||
# one synchronous pass), which is why new sessions saw zero context
|
||||
# until turn 2. Subsequent turns consume the background-refreshed result
|
||||
# primed by queue_prefetch() at the end of the previous turn.
|
||||
with self._base_context_lock:
|
||||
_first_base_fetch = self._base_context_cache is None
|
||||
if _first_base_fetch:
|
||||
self._base_context_cache = ""
|
||||
self._last_context_turn = self._turn_count
|
||||
base_context = self._base_context_cache
|
||||
# Skipped when injectionFrequency is "first-turn" past turn 1 — the
|
||||
# representation + card are static within a session and re-injecting
|
||||
# them every turn is pure token waste.
|
||||
if _skip_base:
|
||||
# Still drain any pending dialectic result below; just don't
|
||||
# fetch or inject the base context layer.
|
||||
pass
|
||||
else:
|
||||
# Base context is pure retrieval (no LLM): representation + peer card +
|
||||
# session summary. On the FIRST fetch (cache is None, i.e. turn 1 of a
|
||||
# session) we fetch it SYNCHRONOUSLY with a short bound so the peer card
|
||||
# is injected immediately. Firing it async and popping in the same call
|
||||
# always lost the race on turn 1 (background thread can't finish inside
|
||||
# one synchronous pass), which is why new sessions saw zero context
|
||||
# until turn 2. Subsequent turns consume the background-refreshed result
|
||||
# primed by queue_prefetch() at the end of the previous turn.
|
||||
with self._base_context_lock:
|
||||
_first_base_fetch = self._base_context_cache is None
|
||||
if _first_base_fetch:
|
||||
self._base_context_cache = ""
|
||||
self._last_context_turn = self._turn_count
|
||||
base_context = self._base_context_cache
|
||||
|
||||
if _first_base_fetch and self._manager:
|
||||
# Synchronous, bounded fetch so turn 1 gets the peer card now.
|
||||
_ctx_holder: dict[str, dict] = {}
|
||||
if _first_base_fetch and self._manager:
|
||||
# Synchronous, bounded fetch so turn 1 gets the peer card now.
|
||||
_ctx_holder: dict[str, dict] = {}
|
||||
|
||||
def _fetch_base() -> None:
|
||||
try:
|
||||
_ctx_holder["ctx"] = self._manager.get_prefetch_context(
|
||||
self._session_key, query or None
|
||||
) or {}
|
||||
except Exception as e:
|
||||
logger.debug("Honcho first-turn base context failed: %s", e)
|
||||
def _fetch_base() -> None:
|
||||
try:
|
||||
_ctx_holder["ctx"] = self._manager.get_prefetch_context(
|
||||
self._session_key, query or None
|
||||
) or {}
|
||||
except Exception as e:
|
||||
logger.debug("Honcho first-turn base context failed: %s", e)
|
||||
|
||||
_bt = threading.Thread(
|
||||
target=_fetch_base, daemon=True, name="honcho-base-first"
|
||||
)
|
||||
_bt.start()
|
||||
# Bound the synchronous wait by the smaller of the base timeout and
|
||||
# any configured request timeout, so a deliberately tight
|
||||
# config.timeout (fail-fast deployments, tests) is still honored.
|
||||
_base_wait = self._FIRST_TURN_BASE_TIMEOUT
|
||||
if self._config and self._config.timeout:
|
||||
_base_wait = min(_base_wait, self._config.timeout)
|
||||
_bt.join(timeout=_base_wait)
|
||||
_ctx = _ctx_holder.get("ctx")
|
||||
if _ctx:
|
||||
formatted = self._format_first_turn_context(_ctx)
|
||||
if formatted:
|
||||
with self._base_context_lock:
|
||||
self._base_context_cache = formatted
|
||||
base_context = formatted
|
||||
elif _bt.is_alive():
|
||||
logger.debug(
|
||||
"Honcho first-turn base context still running after %.1fs — "
|
||||
"will surface on next turn", self._FIRST_TURN_BASE_TIMEOUT,
|
||||
_bt = threading.Thread(
|
||||
target=_fetch_base, daemon=True, name="honcho-base-first"
|
||||
)
|
||||
_bt.start()
|
||||
# Bound the synchronous wait by the smaller of the base timeout and
|
||||
# any configured request timeout, so a deliberately tight
|
||||
# config.timeout (fail-fast deployments, tests) is still honored.
|
||||
_base_wait = self._FIRST_TURN_BASE_TIMEOUT
|
||||
if self._config and self._config.timeout:
|
||||
_base_wait = min(_base_wait, self._config.timeout)
|
||||
_bt.join(timeout=_base_wait)
|
||||
_ctx = _ctx_holder.get("ctx")
|
||||
if _ctx:
|
||||
formatted = self._format_first_turn_context(_ctx)
|
||||
if formatted:
|
||||
with self._base_context_lock:
|
||||
self._base_context_cache = formatted
|
||||
base_context = formatted
|
||||
elif _bt.is_alive():
|
||||
logger.debug(
|
||||
"Honcho first-turn base context still running after %.1fs — "
|
||||
"will surface on next turn", self._FIRST_TURN_BASE_TIMEOUT,
|
||||
)
|
||||
|
||||
# Check if a background context prefetch (primed last turn) has a
|
||||
# fresher result. On turn 1 this is normally empty; turn 2+ consumes
|
||||
# the result queued by queue_prefetch().
|
||||
if not _first_base_fetch and self._manager:
|
||||
fresh_ctx = self._manager.pop_context_result(self._session_key)
|
||||
if fresh_ctx:
|
||||
formatted = self._format_first_turn_context(fresh_ctx)
|
||||
if formatted:
|
||||
with self._base_context_lock:
|
||||
self._base_context_cache = formatted
|
||||
base_context = formatted
|
||||
# Check if a background context prefetch (primed last turn) has a
|
||||
# fresher result. On turn 1 this is normally empty; turn 2+ consumes
|
||||
# the result queued by queue_prefetch().
|
||||
if not _first_base_fetch and self._manager:
|
||||
fresh_ctx = self._manager.pop_context_result(self._session_key)
|
||||
if fresh_ctx:
|
||||
formatted = self._format_first_turn_context(fresh_ctx)
|
||||
if formatted:
|
||||
with self._base_context_lock:
|
||||
self._base_context_cache = formatted
|
||||
base_context = formatted
|
||||
|
||||
if base_context:
|
||||
parts.append(base_context)
|
||||
if base_context:
|
||||
parts.append(base_context)
|
||||
|
||||
# ----- Layer 2: Dialectic supplement -----
|
||||
# On the very first turn, no queue_prefetch() has run yet so the
|
||||
|
|
|
|||
|
|
@ -1489,7 +1489,7 @@ class TestDialecticLiveness:
|
|||
def test_stale_pending_result_is_discarded_on_read(self):
|
||||
"""A pending dialectic result from many turns ago is discarded
|
||||
instead of injected against a fresh conversational pivot."""
|
||||
p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 2}})
|
||||
p = self._make_provider(cfg_extra={"dialectic_cadence": 2})
|
||||
p._session_key = "test"
|
||||
p._base_context_cache = "base ctx"
|
||||
with p._prefetch_lock:
|
||||
|
|
@ -1508,7 +1508,7 @@ class TestDialecticLiveness:
|
|||
|
||||
def test_fresh_pending_result_is_kept(self):
|
||||
"""A pending result within the staleness window is injected normally."""
|
||||
p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 3}})
|
||||
p = self._make_provider(cfg_extra={"dialectic_cadence": 3})
|
||||
p._session_key = "test"
|
||||
p._base_context_cache = ""
|
||||
with p._prefetch_lock:
|
||||
|
|
@ -1522,14 +1522,14 @@ class TestDialecticLiveness:
|
|||
|
||||
def test_empty_streak_widens_effective_cadence(self):
|
||||
"""After N empty returns, the gate waits cadence + N turns."""
|
||||
p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 1}})
|
||||
p = self._make_provider(cfg_extra={"dialectic_cadence": 1})
|
||||
p._dialectic_empty_streak = 3
|
||||
# cadence=1, streak=3 → effective = 4
|
||||
assert p._effective_cadence() == 4
|
||||
|
||||
def test_backoff_is_capped(self):
|
||||
"""Effective cadence is capped at cadence × _BACKOFF_MAX."""
|
||||
p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 2}})
|
||||
p = self._make_provider(cfg_extra={"dialectic_cadence": 2})
|
||||
p._dialectic_empty_streak = 100
|
||||
# cadence=2, ceiling = 2 × 8 = 16
|
||||
assert p._effective_cadence() == 16
|
||||
|
|
@ -1537,7 +1537,7 @@ class TestDialecticLiveness:
|
|||
def test_success_resets_empty_streak(self):
|
||||
"""A non-empty result zeroes the streak so healthy operation restores
|
||||
the base cadence immediately."""
|
||||
p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 1}})
|
||||
p = self._make_provider(cfg_extra={"dialectic_cadence": 1})
|
||||
p._session_key = "test"
|
||||
p._dialectic_empty_streak = 5
|
||||
p._turn_count = 10
|
||||
|
|
@ -1551,7 +1551,7 @@ class TestDialecticLiveness:
|
|||
assert p._last_dialectic_turn == 10
|
||||
|
||||
def test_empty_result_increments_streak(self):
|
||||
p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 1}})
|
||||
p = self._make_provider(cfg_extra={"dialectic_cadence": 1})
|
||||
p._session_key = "test"
|
||||
p._turn_count = 5
|
||||
p._last_dialectic_turn = 0
|
||||
|
|
@ -1638,7 +1638,7 @@ class TestDialecticLifecycleSmoke:
|
|||
"""
|
||||
from unittest.mock import patch, MagicMock
|
||||
provider, mgr, cfg = self._make_provider(
|
||||
cfg_extra={"raw": {"dialecticCadence": 3}}
|
||||
cfg_extra={"dialectic_cadence": 3}
|
||||
)
|
||||
|
||||
# Program the dialectic responses in the exact order they'll be requested.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue