From b2af8809a60a4bce772115b7ea79999c1c806e2f Mon Sep 17 00:00:00 2001 From: Erosika Date: Sat, 18 Apr 2026 09:35:42 -0400 Subject: [PATCH 1/7] =?UTF-8?q?fix(honcho):=20dialectic=20lifecycle=20?= =?UTF-8?q?=E2=80=94=20defaults,=20retry,=20prewarm=20consumption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Several correctness and cost-safety fixes to the Honcho dialectic path after a multi-turn investigation surfaced a chain of silent failures: - dialecticCadence default flipped 3 → 1. PR #10619 changed this from 1 to 3 for cost, but existing installs with no explicit config silently went from per-turn dialectic to every-3-turns on upgrade. Restores pre-#10619 behavior; 3+ remains available for cost-conscious setups. Docs + wizard + status output updated to match. - Session-start prewarm now consumed. Previously fired a .chat() on init whose result landed in HonchoSessionManager._dialectic_cache and was never read — pop_dialectic_result had zero call sites. Turn 1 paid for a duplicate synchronous dialectic. Prewarm now writes directly to the plugin's _prefetch_result via _prefetch_lock so turn 1 consumes it with no extra call. - Prewarm is now dialecticDepth-aware. A single-pass prewarm can return weak output on cold peers; the multi-pass audit/reconcile cycle is exactly the case dialecticDepth was built for. Prewarm now runs the full configured depth in the background. - Silent dialectic failure no longer burns the cadence window. _last_dialectic_turn now advances only when the result is non-empty. Empty result → next eligible turn retries immediately instead of waiting the full cadence gap. - Thread pile-up guard. queue_prefetch skips when a prior dialectic thread is still in-flight, preventing stacked races on _prefetch_result. - First-turn sync timeout is recoverable. Previously on timeout the background thread's result was stored in a dead local list. Now the thread writes into _prefetch_result under lock so the next turn picks it up. - Cadence gate applies uniformly. At cadence=1 the old "cadence > 1" guard let first-turn sync + same-turn queue_prefetch both fire. Gate now always applies. - Restored query-length reasoning-level scaling, dropped in 9a0ab34c. Scales dialecticReasoningLevel up on longer queries (+1 at ≥120 chars, +2 at ≥400), clamped at reasoningLevelCap. Two new config keys: `reasoningHeuristic` (bool, default true) and `reasoningLevelCap` (string, default "high"; previously parsed but never enforced). Respects dialecticDepthLevels and proportional lighter-early passes. - Restored short-prompt skip, dropped in ef7f3156. One-word acknowledgements ("ok", "y", "thanks") and slash commands bypass both injection and dialectic fire. - Purged dead code in session.py: prefetch_dialectic, _dialectic_cache, set_dialectic_result, pop_dialectic_result — all unused after prewarm refactor. Tests: 542 passed across honcho_plugin/, agent/test_memory_provider.py, and run_agent/test_run_agent.py. New coverage: - TestTrivialPromptHeuristic (classifier + prefetch/queue skip) - TestDialecticCadenceAdvancesOnSuccess (empty-result retry, pile-up guard) - TestSessionStartDialecticPrewarm (prewarm consumed, sync fallback) - TestReasoningHeuristic (length bumps, cap clamp, interaction with depth) - TestDialecticLifecycleSmoke (end-to-end 8-turn session walk) --- .../autonomous-ai-agents/honcho/SKILL.md | 6 +- plugins/memory/honcho/__init__.py | 199 ++++++-- plugins/memory/honcho/cli.py | 8 +- plugins/memory/honcho/client.py | 18 + plugins/memory/honcho/session.py | 46 +- tests/agent/test_memory_provider.py | 2 - tests/honcho_plugin/test_async_memory.py | 7 - tests/honcho_plugin/test_session.py | 478 +++++++++++++++++- website/docs/user-guide/features/honcho.md | 4 +- .../user-guide/features/memory-providers.md | 4 +- 10 files changed, 665 insertions(+), 107 deletions(-) diff --git a/optional-skills/autonomous-ai-agents/honcho/SKILL.md b/optional-skills/autonomous-ai-agents/honcho/SKILL.md index c60d2c63561c..5d03a549858a 100644 --- a/optional-skills/autonomous-ai-agents/honcho/SKILL.md +++ b/optional-skills/autonomous-ai-agents/honcho/SKILL.md @@ -145,10 +145,10 @@ Controls **how often** dialectic and context calls happen. | Key | Default | Description | |-----|---------|-------------| | `contextCadence` | `1` | Min turns between context API calls | -| `dialecticCadence` | `3` | Min turns between dialectic API calls | +| `dialecticCadence` | `1` | Min turns between dialectic API calls | | `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` for base context injection | -Higher cadence values reduce API calls and cost. `dialecticCadence: 3` (default) means the dialectic engine fires at most every 3rd turn. +Higher cadence values reduce API calls and cost. `dialecticCadence: 1` (default) fires every turn; set to `3` or higher to throttle for cost. ### Depth (how many) @@ -368,7 +368,7 @@ Config file: `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.jso | `contextTokens` | uncapped | Max tokens for the combined base context injection (summary + representation + card). Opt-in cap — omit to leave uncapped, set to an integer to bound injection size. | | `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` | | `contextCadence` | `1` | Min turns between context API calls | -| `dialecticCadence` | `3` | Min turns between dialectic LLM calls | +| `dialecticCadence` | `1` | Min turns between dialectic LLM calls | The `contextTokens` budget is enforced at injection time. If the session summary + representation + card exceed the budget, Honcho trims the summary first, then the representation, preserving the card. This prevents context blowup in long sessions. diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index ca44ce60193d..ac0f60279a6c 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -206,10 +206,11 @@ def __init__(self): self._turn_count = 0 self._injection_frequency = "every-turn" # or "first-turn" self._context_cadence = 1 # minimum turns between context API calls - self._dialectic_cadence = 3 # minimum turns between dialectic API calls + self._dialectic_cadence = 1 # minimum turns between dialectic API calls self._dialectic_depth = 1 # how many .chat() calls per dialectic cycle (1-3) self._dialectic_depth_levels: list[str] | None = None # per-pass reasoning levels - self._reasoning_level_cap: Optional[str] = None # "minimal", "low", "medium", "high" + self._reasoning_heuristic: bool = True # scale base level by query length + self._reasoning_level_cap: str = "high" # ceiling for auto-selected level self._last_context_turn = -999 self._last_dialectic_turn = -999 @@ -305,12 +306,12 @@ def initialize(self, session_id: str, **kwargs) -> None: raw = cfg.raw or {} self._injection_frequency = raw.get("injectionFrequency", "every-turn") self._context_cadence = int(raw.get("contextCadence", 1)) - self._dialectic_cadence = int(raw.get("dialecticCadence", 3)) + self._dialectic_cadence = int(raw.get("dialecticCadence", 1)) self._dialectic_depth = max(1, min(cfg.dialectic_depth, 3)) self._dialectic_depth_levels = cfg.dialectic_depth_levels - cap = raw.get("reasoningLevelCap") - if cap and cap in ("minimal", "low", "medium", "high"): - self._reasoning_level_cap = cap + self._reasoning_heuristic = cfg.reasoning_heuristic + if cfg.reasoning_level_cap in self._LEVEL_ORDER: + self._reasoning_level_cap = cfg.reasoning_level_cap except Exception as e: logger.debug("Honcho cost-awareness config parse error: %s", e) @@ -391,14 +392,42 @@ def _do_session_init(self, cfg, session_id: str, **kwargs) -> None: except Exception as e: logger.debug("Honcho memory file migration skipped: %s", e) - # ----- B7: Pre-warming context at init ----- + # ----- B7: Pre-warming at init ----- + # Context prewarm: warms peer.context() cache (base layer), consumed + # via pop_context_result() in prefetch(). + # Dialectic prewarm: fires a depth-aware cycle against the plugin's + # own _prefetch_result so turn 1 can consume it directly. Without this + # the first-turn sync path pays for a duplicate .chat() — and at + # depth>1 a single-pass session-start dialectic often returns weak + # output that multi-pass audit/reconciliation is meant to catch. if self._recall_mode in ("context", "hybrid"): try: self._manager.prefetch_context(self._session_key) - self._manager.prefetch_dialectic(self._session_key, "What should I know about this user?") - logger.debug("Honcho pre-warm threads started for session: %s", self._session_key) except Exception as e: - logger.debug("Honcho pre-warm failed: %s", e) + logger.debug("Honcho context prewarm failed: %s", e) + + _prewarm_query = ( + "Summarize what you know about this user. " + "Focus on preferences, current projects, and working style." + ) + + def _prewarm_dialectic() -> None: + try: + r = self._run_dialectic_depth(_prewarm_query) + except Exception as exc: + logger.debug("Honcho dialectic prewarm failed: %s", exc) + return + if r and r.strip(): + with self._prefetch_lock: + self._prefetch_result = r + # Treat prewarm as turn 0 so cadence gating starts clean. + self._last_dialectic_turn = 0 + + self._prefetch_thread = threading.Thread( + target=_prewarm_dialectic, daemon=True, name="honcho-prewarm-dialectic" + ) + self._prefetch_thread.start() + logger.debug("Honcho pre-warm started for session: %s", self._session_key) def _ensure_session(self) -> bool: """Lazily initialize the Honcho session (for tools-only mode). @@ -526,6 +555,11 @@ def prefetch(self, query: str, *, session_id: str = "") -> str: if self._injection_frequency == "first-turn" and self._turn_count > 1: return "" + # Skip trivial prompts — "ok", "yes", slash commands carry no semantic signal, + # so injecting user context there just burns tokens and can derail the reply. + if self._is_trivial_prompt(query): + return "" + parts = [] # ----- Layer 1: Base context (representation + card) ----- @@ -560,37 +594,46 @@ def prefetch(self, query: str, *, session_id: str = "") -> str: # On the very first turn, no queue_prefetch() has run yet so the # dialectic result is empty. Run with a bounded timeout so a slow # Honcho connection doesn't block the first response indefinitely. - # On timeout the result is skipped and queue_prefetch() will pick it - # up at the next cadence-allowed turn. + # On timeout we let the thread keep running and write its result into + # _prefetch_result under the lock, so the next turn picks it up. + # + # Skip if the session-start prewarm already filled _prefetch_result — + # firing another .chat() would be duplicate work. + with self._prefetch_lock: + _prewarm_landed = bool(self._prefetch_result) + if _prewarm_landed and self._last_dialectic_turn == -999: + self._last_dialectic_turn = self._turn_count + if self._last_dialectic_turn == -999 and query: _first_turn_timeout = ( self._config.timeout if self._config and self._config.timeout else 8.0 ) - _result_holder: list[str] = [] + _fired_at = self._turn_count def _run_first_turn() -> None: try: - _result_holder.append(self._run_dialectic_depth(query)) + r = self._run_dialectic_depth(query) except Exception as exc: logger.debug("Honcho first-turn dialectic failed: %s", exc) - - _t = threading.Thread(target=_run_first_turn, daemon=True) - _t.start() - _t.join(timeout=_first_turn_timeout) - if not _t.is_alive(): - first_turn_dialectic = _result_holder[0] if _result_holder else "" - if first_turn_dialectic and first_turn_dialectic.strip(): + return + if r and r.strip(): with self._prefetch_lock: - self._prefetch_result = first_turn_dialectic - self._last_dialectic_turn = self._turn_count - else: + self._prefetch_result = r + # Only advance cadence on a non-empty result so failures + # don't burn a 3-turn cooldown on nothing. + self._last_dialectic_turn = _fired_at + + self._prefetch_thread = threading.Thread( + target=_run_first_turn, daemon=True, name="honcho-prefetch-first" + ) + self._prefetch_thread.start() + self._prefetch_thread.join(timeout=_first_turn_timeout) + if self._prefetch_thread.is_alive(): logger.debug( - "Honcho first-turn dialectic timed out (%.1fs) — " - "will inject at next cadence-allowed turn", + "Honcho first-turn dialectic still running after %.1fs — " + "will surface on next turn", _first_turn_timeout, ) - # Don't update _last_dialectic_turn: queue_prefetch() will - # retry at the next cadence-allowed turn via the async path. if self._prefetch_thread and self._prefetch_thread.is_alive(): self._prefetch_thread.join(timeout=3.0) @@ -641,6 +684,10 @@ def queue_prefetch(self, query: str, *, session_id: str = "") -> None: if self._recall_mode == "tools": return + # Trivial prompts don't warrant either a context refresh or a dialectic call. + if self._is_trivial_prompt(query): + return + # ----- Context refresh (base layer) — independent cadence ----- if self._context_cadence <= 1 or (self._turn_count - self._last_context_turn) >= self._context_cadence: self._last_context_turn = self._turn_count @@ -650,23 +697,35 @@ def queue_prefetch(self, query: str, *, session_id: str = "") -> None: logger.debug("Honcho context prefetch failed: %s", e) # ----- Dialectic prefetch (supplement layer) ----- - # B5: cadence check — skip if too soon since last dialectic call - if self._dialectic_cadence > 1: - if (self._turn_count - self._last_dialectic_turn) < self._dialectic_cadence: - logger.debug("Honcho dialectic prefetch skipped: cadence %d, turns since last: %d", - self._dialectic_cadence, self._turn_count - self._last_dialectic_turn) - return + # Guard against thread pile-up: if a prior dialectic is still in flight, + # let it finish instead of stacking races on _prefetch_result. + if self._prefetch_thread and self._prefetch_thread.is_alive(): + logger.debug("Honcho dialectic prefetch skipped: prior thread still running") + return + + # B5: cadence check — skip if too soon since last *successful* dialectic call. + # The gate applies uniformly (including cadence=1): "every turn" means once + # per turn, not twice on the same turn when first-turn sync already fired. + if (self._turn_count - self._last_dialectic_turn) < self._dialectic_cadence: + logger.debug("Honcho dialectic prefetch skipped: cadence %d, turns since last: %d", + self._dialectic_cadence, self._turn_count - self._last_dialectic_turn) + return - self._last_dialectic_turn = self._turn_count + # Advance cadence only on a non-empty result — otherwise a silent failure + # (empty dialectic, transient API error) would burn the full cadence window + # before the next retry, making it look like dialectic "never fires again". + _fired_at = self._turn_count def _run(): try: result = self._run_dialectic_depth(query) - if result and result.strip(): - with self._prefetch_lock: - self._prefetch_result = result except Exception as e: logger.debug("Honcho prefetch failed: %s", e) + return + if result and result.strip(): + with self._prefetch_lock: + self._prefetch_result = result + self._last_dialectic_turn = _fired_at self._prefetch_thread = threading.Thread( target=_run, daemon=True, name="honcho-prefetch" @@ -692,11 +751,42 @@ def _run(): _LEVEL_ORDER = ("minimal", "low", "medium", "high", "max") - def _resolve_pass_level(self, pass_idx: int) -> str: + # Reasoning-level heuristic thresholds (restored from pre-9a0ab34c behavior). + # Promoted to class constants so tests can override without widening the + # config surface. Bump to config fields only if real use shows they're needed. + _HEURISTIC_LENGTH_MEDIUM = 120 + _HEURISTIC_LENGTH_HIGH = 400 + + def _apply_reasoning_heuristic(self, base: str, query: str) -> str: + """Scale `base` up by query length, clamped at reasoning_level_cap. + + Char-count heuristic: +1 at >=120 chars, +2 at >=400. Ceiling is + reasoning_level_cap (default 'high' — 'max' is reserved for + explicit tool-path selection). + """ + if not self._reasoning_heuristic or not query: + return base + if base not in self._LEVEL_ORDER: + return base + n = len(query) + if n < self._HEURISTIC_LENGTH_MEDIUM: + bump = 0 + elif n < self._HEURISTIC_LENGTH_HIGH: + bump = 1 + else: + bump = 2 + base_idx = self._LEVEL_ORDER.index(base) + cap_idx = self._LEVEL_ORDER.index(self._reasoning_level_cap) + return self._LEVEL_ORDER[min(base_idx + bump, cap_idx)] + + def _resolve_pass_level(self, pass_idx: int, query: str = "") -> str: """Resolve reasoning level for a given pass index. - Uses dialecticDepthLevels if configured, otherwise proportional - defaults relative to dialecticReasoningLevel. + Precedence: + 1. dialecticDepthLevels (explicit per-pass) — wins absolutely + 2. _PROPORTIONAL_LEVELS table (depth>1 lighter-early passes) + 3. Base level = dialecticReasoningLevel, optionally scaled by the + reasoning heuristic when the mapping falls through to 'base' """ if self._dialectic_depth_levels and pass_idx < len(self._dialectic_depth_levels): return self._dialectic_depth_levels[pass_idx] @@ -704,7 +794,7 @@ def _resolve_pass_level(self, pass_idx: int) -> str: base = (self._config.dialectic_reasoning_level if self._config else "low") mapping = self._PROPORTIONAL_LEVELS.get((self._dialectic_depth, pass_idx)) if mapping is None or mapping == "base": - return base + return self._apply_reasoning_heuristic(base, query) return mapping def _build_dialectic_prompt(self, pass_idx: int, prior_results: list[str], is_cold: bool) -> str: @@ -791,7 +881,7 @@ def _run_dialectic_depth(self, query: str) -> str: break prompt = self._build_dialectic_prompt(i, results, is_cold) - level = self._resolve_pass_level(i) + level = self._resolve_pass_level(i, query=query) logger.debug("Honcho dialectic depth %d: pass %d, level=%s, cold=%s", self._dialectic_depth, i, level, is_cold) @@ -808,6 +898,29 @@ def _run_dialectic_depth(self, query: str) -> str: return r return "" + # Prompts that carry no semantic signal — trivial acknowledgements, slash + # commands, empty input. Skipping injection here saves tokens and prevents + # stale user-model context from derailing one-word replies. + _TRIVIAL_PROMPT_RE = re.compile( + r'^(yes|no|ok|okay|sure|thanks|thank you|y|n|yep|nope|yeah|nah|' + r'continue|go ahead|do it|proceed|got it|cool|nice|great|done|next|lgtm|k)$', + re.IGNORECASE, + ) + + @classmethod + def _is_trivial_prompt(cls, text: str) -> bool: + """Return True if the prompt is too trivial to warrant context injection.""" + if not text: + return True + stripped = text.strip() + if not stripped: + return True + if stripped.startswith("/"): + return True + if cls._TRIVIAL_PROMPT_RE.match(stripped): + return True + return False + def on_turn_start(self, turn_number: int, message: str, **kwargs) -> None: """Track turn count for cadence and injection_frequency logic.""" self._turn_count = turn_number diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 536d34002de0..478bf39d8a6f 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -460,17 +460,17 @@ def cmd_setup(args) -> None: pass # keep current # --- 7b. Dialectic cadence --- - current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "3") + current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "1") print("\n Dialectic cadence:") print(" How often Honcho rebuilds its user model (LLM call on Honcho backend).") - print(" 1 = every turn (aggressive), 3 = every 3 turns (recommended), 5+ = sparse.") + print(" 1 = every turn (default), 3+ = sparse (cost-saving).") new_dialectic = _prompt("Dialectic cadence", default=current_dialectic) try: val = int(new_dialectic) if val >= 1: hermes_host["dialecticCadence"] = val except (ValueError, TypeError): - hermes_host["dialecticCadence"] = 3 + hermes_host["dialecticCadence"] = 1 # --- 8. Session strategy --- current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-session") @@ -636,7 +636,7 @@ def cmd_status(args) -> None: print(f" Recall mode: {hcfg.recall_mode}") print(f" Context budget: {hcfg.context_tokens or '(uncapped)'} tokens") raw = getattr(hcfg, "raw", None) or {} - dialectic_cadence = raw.get("dialecticCadence") or 3 + dialectic_cadence = raw.get("dialecticCadence") or 1 print(f" Dialectic cad: every {dialectic_cadence} turn{'s' if dialectic_cadence != 1 else ''}") print(f" Observation: user(me={hcfg.user_observe_me},others={hcfg.user_observe_others}) ai(me={hcfg.ai_observe_me},others={hcfg.ai_observe_others})") print(f" Write freq: {hcfg.write_frequency}") diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 2474d3a2b65e..136b1e60dc61 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -251,6 +251,14 @@ class HonchoClientConfig: # matching dialectic_depth length. When None, uses proportional defaults # derived from dialectic_reasoning_level. dialectic_depth_levels: list[str] | None = None + # Reasoning-level heuristic for auto-injected dialectic calls. When true, + # scales the base level up on longer queries (restored from pre-#10619 + # behavior; see plugins/memory/honcho/__init__.py for thresholds). + # Never auto-selects a level above reasoning_level_cap. + reasoning_heuristic: bool = True + # Ceiling for heuristic-selected reasoning level. "max" is reserved for + # explicit tool-path selection; default "high" matches the old behavior. + reasoning_level_cap: str = "high" # Honcho API limits — configurable for self-hosted instances # Max chars per message sent via add_messages() (Honcho cloud: 25000) message_max_chars: int = 25000 @@ -446,6 +454,16 @@ def from_global_config( raw.get("dialecticDepthLevels"), depth=_parse_dialectic_depth(host_block.get("dialecticDepth"), raw.get("dialecticDepth")), ), + reasoning_heuristic=_resolve_bool( + host_block.get("reasoningHeuristic"), + raw.get("reasoningHeuristic"), + default=True, + ), + reasoning_level_cap=( + host_block.get("reasoningLevelCap") + or raw.get("reasoningLevelCap") + or "high" + ), message_max_chars=int( host_block.get("messageMaxChars") or raw.get("messageMaxChars") diff --git a/plugins/memory/honcho/session.py b/plugins/memory/honcho/session.py index fd91ee3b3b94..7344b517e407 100644 --- a/plugins/memory/honcho/session.py +++ b/plugins/memory/honcho/session.py @@ -100,9 +100,11 @@ def __init__( self._write_frequency = write_frequency self._turn_counter: int = 0 - # Prefetch caches: session_key → last result (consumed once per turn) + # Prefetch cache: session_key → last context result (consumed once per turn). + # Dialectic results are cached on the plugin side (HonchoMemoryProvider + # ._prefetch_result) so session-start prewarm and turn-driven fires share + # one source of truth; see __init__.py _do_session_init for the prewarm. self._context_cache: dict[str, dict] = {} - self._dialectic_cache: dict[str, str] = {} self._prefetch_cache_lock = threading.Lock() self._dialectic_reasoning_level: str = ( config.dialectic_reasoning_level if config else "low" @@ -499,8 +501,8 @@ def dialectic_query( Query Honcho's dialectic endpoint about a peer. Runs an LLM on Honcho's backend against the target peer's full - representation. Higher latency than context() — call async via - prefetch_dialectic() to avoid blocking the response. + representation. Higher latency than context() — callers run this in + a background thread (see HonchoMemoryProvider) to avoid blocking. Args: session_key: The session key to query against. @@ -555,42 +557,6 @@ def dialectic_query( logger.warning("Honcho dialectic query failed: %s", e) return "" - def prefetch_dialectic(self, session_key: str, query: str) -> None: - """ - Fire a dialectic_query in a background thread, caching the result. - - Non-blocking. The result is available via pop_dialectic_result() - on the next call (typically the following turn). Reasoning level - is selected dynamically based on query complexity. - - Args: - session_key: The session key to query against. - query: The user's current message, used as the query. - """ - def _run(): - result = self.dialectic_query(session_key, query) - if result: - self.set_dialectic_result(session_key, result) - - t = threading.Thread(target=_run, name="honcho-dialectic-prefetch", daemon=True) - t.start() - - def set_dialectic_result(self, session_key: str, result: str) -> None: - """Store a prefetched dialectic result in a thread-safe way.""" - if not result: - return - with self._prefetch_cache_lock: - self._dialectic_cache[session_key] = result - - def pop_dialectic_result(self, session_key: str) -> str: - """ - Return and clear the cached dialectic result for this session. - - Returns empty string if no result is ready yet. - """ - with self._prefetch_cache_lock: - return self._dialectic_cache.pop(session_key, "") - def prefetch_context(self, session_key: str, user_message: str | None = None) -> None: """ Fire get_prefetch_context in a background thread, caching the result. diff --git a/tests/agent/test_memory_provider.py b/tests/agent/test_memory_provider.py index 9301960b717f..5cd0d8ab4136 100644 --- a/tests/agent/test_memory_provider.py +++ b/tests/agent/test_memory_provider.py @@ -971,8 +971,6 @@ def test_queue_prefetch_respects_dialectic_cadence(self): class FakeManager: def prefetch_context(self, key, query=None): pass - def prefetch_dialectic(self, key, query): - pass p._manager = FakeManager() diff --git a/tests/honcho_plugin/test_async_memory.py b/tests/honcho_plugin/test_async_memory.py index 936f478846f4..5df8d2745402 100644 --- a/tests/honcho_plugin/test_async_memory.py +++ b/tests/honcho_plugin/test_async_memory.py @@ -460,10 +460,3 @@ def test_set_and_pop_context_result(self): assert mgr.pop_context_result("cli:test") == payload assert mgr.pop_context_result("cli:test") == {} - def test_set_and_pop_dialectic_result(self): - mgr = _make_manager(write_frequency="turn") - - mgr.set_dialectic_result("cli:test", "Resume with toolset cleanup") - - assert mgr.pop_dialectic_result("cli:test") == "Resume with toolset cleanup" - assert mgr.pop_dialectic_result("cli:test") == "" diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index 9784959d37d9..b0282b1969c9 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -815,6 +815,24 @@ def test_long_query_truncated(self): # --------------------------------------------------------------------------- +def _settle_prewarm(provider): + """Wait for the session-start prewarm dialectic thread, then return the + provider to a clean 'nothing fired yet' state so cadence/first-turn/ + trivial-prompt tests can assert from a known baseline.""" + if provider._prefetch_thread: + provider._prefetch_thread.join(timeout=3.0) + with provider._prefetch_lock: + provider._prefetch_result = "" + provider._prefetch_thread = None + provider._last_dialectic_turn = -999 + if getattr(provider, "_manager", None) is not None: + try: + provider._manager.dialectic_query.reset_mock() + provider._manager.prefetch_context.reset_mock() + except AttributeError: + pass + + class TestDialecticCadenceDefaults: """Regression tests for dialectic_cadence default value.""" @@ -840,12 +858,15 @@ def _make_provider(cfg_extra=None): patch("hermes_constants.get_hermes_home", return_value=MagicMock()): provider.initialize(session_id="test-session-001") + _settle_prewarm(provider) return provider - def test_default_is_3(self): - """Default dialectic_cadence should be 3 to avoid per-turn LLM calls.""" + def test_default_is_1(self): + """Default dialectic_cadence should be 1 (every turn) — restored from + pre-#10619 behavior to avoid a silent regression on upgrade for users + who never set dialecticCadence explicitly.""" provider = self._make_provider() - assert provider._dialectic_cadence == 3 + assert provider._dialectic_cadence == 1 def test_config_override(self): """dialecticCadence from config overrides the default.""" @@ -908,6 +929,7 @@ def _make_provider(cfg_extra=None): patch("hermes_constants.get_hermes_home", return_value=MagicMock()): provider.initialize(session_id="test-session-001") + _settle_prewarm(provider) return provider def test_default_depth_is_1(self): @@ -1062,7 +1084,8 @@ def test_first_turn_dialectic_does_not_double_fire(self): provider.prefetch("hello") assert provider._manager.dialectic_query.call_count == 1 - # Now queue_prefetch on same turn should skip (cadence: 0 - 0 < 3) + # Now queue_prefetch on same turn should skip — _last_dialectic_turn + # was just set to _turn_count by the sync path, so (0 - 0 = 0) < cadence. provider._manager.dialectic_query.reset_mock() provider.queue_prefetch("hello") assert provider._manager.dialectic_query.call_count == 0 @@ -1083,6 +1106,453 @@ def test_run_dialectic_depth_bails_early_on_strong_signal(self): assert provider._manager.dialectic_query.call_count == 1 +# --------------------------------------------------------------------------- +# Trivial-prompt heuristic + dialectic cadence silent-failure guards +# --------------------------------------------------------------------------- + + +class TestTrivialPromptHeuristic: + """Trivial prompts ('ok', 'y', slash commands) must short-circuit injection. + + Restored after accidental removal during the two-layer prefetch refactor. + """ + + @staticmethod + def _make_provider(): + from unittest.mock import patch, MagicMock + from plugins.memory.honcho.client import HonchoClientConfig + + cfg = HonchoClientConfig(api_key="test-key", enabled=True, recall_mode="hybrid") + provider = HonchoMemoryProvider() + mock_manager = MagicMock() + mock_session = MagicMock() + mock_session.messages = [] + mock_manager.get_or_create.return_value = mock_session + + with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ + patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ + patch("hermes_constants.get_hermes_home", return_value=MagicMock()): + provider.initialize(session_id="test-session-trivial") + _settle_prewarm(provider) + return provider + + def test_classifier_catches_common_trivial_forms(self): + for t in ("ok", "OK", " ok ", "y", "yes", "sure", "thanks", "lgtm", "/help", "", " "): + assert HonchoMemoryProvider._is_trivial_prompt(t), f"expected trivial: {t!r}" + + def test_classifier_lets_substantive_prompts_through(self): + for t in ("hello world", "what's my name", "explain this", "ok so what's next"): + assert not HonchoMemoryProvider._is_trivial_prompt(t), f"expected non-trivial: {t!r}" + + def test_prefetch_skips_on_trivial_prompt(self): + provider = self._make_provider() + provider._session_key = "test" + provider._base_context_cache = "cached base" + provider._last_dialectic_turn = 0 + provider._turn_count = 5 + + assert provider.prefetch("ok") == "" + assert provider.prefetch("/help") == "" + # Dialectic should not have fired + assert provider._manager.dialectic_query.call_count == 0 + + def test_queue_prefetch_skips_on_trivial_prompt(self): + provider = self._make_provider() + provider._session_key = "test" + provider._turn_count = 10 + provider._last_dialectic_turn = -999 # would otherwise fire + # initialize() pre-warms; clear call counts before the assertion. + provider._manager.prefetch_context.reset_mock() + provider._manager.dialectic_query.reset_mock() + + provider.queue_prefetch("y") + # Trivial prompts short-circuit both context refresh and dialectic fire. + assert provider._manager.prefetch_context.call_count == 0 + assert provider._manager.dialectic_query.call_count == 0 + + +class TestDialecticCadenceAdvancesOnSuccess: + """Cadence tracker must only advance when the dialectic call actually returned. + + A silent failure (empty result, API blip) used to burn the full cadence window + before retrying — making it look like dialectic 'never fires again'. + """ + + @staticmethod + def _make_provider(): + from unittest.mock import patch, MagicMock + from plugins.memory.honcho.client import HonchoClientConfig + + cfg = HonchoClientConfig( + api_key="test-key", enabled=True, recall_mode="hybrid", dialectic_depth=1, + ) + provider = HonchoMemoryProvider() + mock_manager = MagicMock() + mock_session = MagicMock() + mock_session.messages = [] + mock_manager.get_or_create.return_value = mock_session + + with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ + patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ + patch("hermes_constants.get_hermes_home", return_value=MagicMock()): + provider.initialize(session_id="test-session-retry") + _settle_prewarm(provider) + return provider + + def test_empty_dialectic_result_does_not_advance_cadence(self): + import time as _time + provider = self._make_provider() + provider._session_key = "test" + provider._manager.dialectic_query.return_value = "" # silent failure + provider._turn_count = 5 + provider._last_dialectic_turn = 0 # would fire (5 - 0 = 5 ≥ 3) + + provider.queue_prefetch("hello") + # wait for the background thread to settle + if provider._prefetch_thread: + provider._prefetch_thread.join(timeout=2.0) + + # Dialectic call was attempted + assert provider._manager.dialectic_query.call_count == 1 + # But cadence tracker did NOT advance — next turn should retry + assert provider._last_dialectic_turn == 0 + + def test_non_empty_dialectic_result_advances_cadence(self): + provider = self._make_provider() + provider._session_key = "test" + provider._manager.dialectic_query.return_value = "real synthesis output" + provider._turn_count = 5 + provider._last_dialectic_turn = 0 + + provider.queue_prefetch("hello") + if provider._prefetch_thread: + provider._prefetch_thread.join(timeout=2.0) + + assert provider._last_dialectic_turn == 5 + + def test_in_flight_thread_is_not_stacked(self): + import threading as _threading + provider = self._make_provider() + provider._session_key = "test" + provider._turn_count = 10 + provider._last_dialectic_turn = 0 + + # Simulate a prior thread still running + hold = _threading.Event() + + def _block(): + hold.wait(timeout=5.0) + + stale = _threading.Thread(target=_block, daemon=True) + stale.start() + provider._prefetch_thread = stale + + provider.queue_prefetch("hello") + # Should have short-circuited — no new dialectic call + assert provider._manager.dialectic_query.call_count == 0 + hold.set() + stale.join(timeout=2.0) + + +class TestSessionStartDialecticPrewarm: + """Session-start prewarm fires a depth-aware dialectic whose result is + consumed by turn 1 — no duplicate .chat() and no dead-cache orphaning.""" + + @staticmethod + def _make_provider(cfg_extra=None, dialectic_result="prewarm synthesis"): + from unittest.mock import patch, MagicMock + from plugins.memory.honcho.client import HonchoClientConfig + + defaults = dict(api_key="test-key", enabled=True, recall_mode="hybrid") + if cfg_extra: + defaults.update(cfg_extra) + cfg = HonchoClientConfig(**defaults) + provider = HonchoMemoryProvider() + mock_manager = MagicMock() + mock_manager.get_or_create.return_value = MagicMock(messages=[]) + mock_manager.get_prefetch_context.return_value = None + mock_manager.pop_context_result.return_value = None + mock_manager.dialectic_query.return_value = dialectic_result + + with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ + patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ + patch("hermes_constants.get_hermes_home", return_value=MagicMock()): + provider.initialize(session_id="test-prewarm") + return provider + + def test_prewarm_populates_prefetch_result(self): + p = self._make_provider() + # Wait for prewarm thread to land + if p._prefetch_thread: + p._prefetch_thread.join(timeout=3.0) + with p._prefetch_lock: + assert p._prefetch_result == "prewarm synthesis" + assert p._last_dialectic_turn == 0 + + def test_turn1_consumes_prewarm_without_duplicate_dialectic(self): + """With prewarm result already in _prefetch_result, turn 1 prefetch + should NOT fire another dialectic.""" + p = self._make_provider() + if p._prefetch_thread: + p._prefetch_thread.join(timeout=3.0) + p._manager.dialectic_query.reset_mock() + p._session_key = "test-prewarm" + p._base_context_cache = "" + p._turn_count = 1 + + result = p.prefetch("hello world") + assert "prewarm synthesis" in result + # The sync first-turn path must NOT have fired another .chat() + assert p._manager.dialectic_query.call_count == 0 + + def test_turn1_falls_back_to_sync_when_prewarm_missing(self): + """If the prewarm produced nothing (empty graph, API blip), turn 1 + still fires its own sync dialectic.""" + p = self._make_provider(dialectic_result="") # prewarm returns empty + if p._prefetch_thread: + p._prefetch_thread.join(timeout=3.0) + with p._prefetch_lock: + assert p._prefetch_result == "" # prewarm landed nothing + # Switch dialectic_query to return something on the sync first-turn call + p._manager.dialectic_query.return_value = "sync recovery" + p._manager.dialectic_query.reset_mock() + p._session_key = "test-prewarm" + p._base_context_cache = "" + p._turn_count = 1 + + result = p.prefetch("hello world") + assert "sync recovery" in result + assert p._manager.dialectic_query.call_count == 1 + + +class TestDialecticLifecycleSmoke: + """End-to-end smoke: walks a realistic multi-turn session through every + behavior we care about — prewarm → turn 1 consume → trivial skip → cadence + fire → silent-failure retry → heuristic bump → session-end flush. + + This is the 'velvet circuit' test: one provider, one flow, one set of + assertions. If the suite above lies about intent, this one catches it. + """ + + @staticmethod + def _make_provider(cfg_extra=None): + from unittest.mock import patch, MagicMock + from plugins.memory.honcho.client import HonchoClientConfig + + defaults = dict( + api_key="test-key", enabled=True, recall_mode="hybrid", + dialectic_reasoning_level="low", reasoning_heuristic=True, + reasoning_level_cap="high", dialectic_depth=1, + ) + if cfg_extra: + defaults.update(cfg_extra) + cfg = HonchoClientConfig(**defaults) + provider = HonchoMemoryProvider() + mock_manager = MagicMock() + mock_session = MagicMock() + mock_session.messages = [] + mock_manager.get_or_create.return_value = mock_session + mock_manager.get_prefetch_context.return_value = None + mock_manager.pop_context_result.return_value = None + + with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ + patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ + patch("hermes_constants.get_hermes_home", return_value=MagicMock()): + return provider, mock_manager, cfg + + def _await_thread(self, provider): + if provider._prefetch_thread: + provider._prefetch_thread.join(timeout=3.0) + + def test_full_multi_turn_session(self): + """Walks init → turns 1..8 → session end. Asserts at every step that + the plugin did exactly what it should and nothing more. + + Uses dialecticCadence=3 so we can exercise skip-turns between fires + and the silent-failure retry path without their gates tripping each + other. Trivial + slash skips apply independent of cadence. + """ + from unittest.mock import patch, MagicMock + provider, mgr, cfg = self._make_provider( + cfg_extra={"raw": {"dialecticCadence": 3}} + ) + + # Program the dialectic responses in the exact order they'll be requested. + # An extra or missing call fails the test — strong smoke signal. + responses = iter([ + "prewarm: user is eri, works on hermes", # session-start prewarm + "cadence fire: long query synthesis", # turn 4 queue_prefetch + "", # turn 7 fire: silent failure + "retry success: fresh synthesis", # turn 8 queue_prefetch retry + ]) + mgr.dialectic_query.side_effect = lambda *a, **kw: next(responses) + + # ---- init: prewarm fires ---- + with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ + patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mgr), \ + patch("hermes_constants.get_hermes_home", return_value=MagicMock()): + provider.initialize(session_id="smoke-test") + + self._await_thread(provider) + with provider._prefetch_lock: + assert provider._prefetch_result.startswith("prewarm"), \ + "session-start prewarm must land in _prefetch_result" + assert provider._last_dialectic_turn == 0, "prewarm marks turn 0" + assert mgr.dialectic_query.call_count == 1 + + # ---- turn 1: consume prewarm, no duplicate dialectic ---- + provider.on_turn_start(1, "hey") + inject1 = provider.prefetch("hey") + assert "prewarm" in inject1, "turn 1 must surface prewarm" + provider.sync_turn("hey", "hi there") + provider.queue_prefetch("hey") # cadence gate: (1-0)<3 → skip + self._await_thread(provider) + assert mgr.dialectic_query.call_count == 1, \ + "turn 1 must not fire — prewarm covered it and cadence skips" + + # ---- turn 2: trivial 'ok' → skip everything ---- + mgr.prefetch_context.reset_mock() + provider.on_turn_start(2, "ok") + assert provider.prefetch("ok") == "", "trivial prompt must short-circuit injection" + provider.sync_turn("ok", "cool") + provider.queue_prefetch("ok") + self._await_thread(provider) + assert mgr.dialectic_query.call_count == 1, "trivial must not fire dialectic" + assert mgr.prefetch_context.call_count == 0, "trivial must not fire context refresh" + + # ---- turn 3: slash '/help' → also skip ---- + provider.on_turn_start(3, "/help") + assert provider.prefetch("/help") == "" + provider.queue_prefetch("/help") + assert mgr.dialectic_query.call_count == 1 + + # ---- turn 4: long query → cadence fires + heuristic bumps ---- + long_q = "walk me through " + ("x " * 100) # ~200 chars → heuristic +1 + provider.on_turn_start(4, long_q) + provider.prefetch(long_q) + provider.sync_turn(long_q, "sure") + provider.queue_prefetch(long_q) # (4-0)≥3 → fires + self._await_thread(provider) + assert mgr.dialectic_query.call_count == 2, "turn 4 cadence fire" + _, kwargs = mgr.dialectic_query.call_args + assert kwargs.get("reasoning_level") in ("medium", "high"), \ + f"long query must bump reasoning level above 'low'; got {kwargs.get('reasoning_level')}" + assert provider._last_dialectic_turn == 4, "cadence tracker advances on success" + + # ---- turns 5–6: cadence cooldown, no fires ---- + for t in (5, 6): + provider.on_turn_start(t, "tell me more") + provider.queue_prefetch("tell me more") + self._await_thread(provider) + assert mgr.dialectic_query.call_count == 2, "turns 5–6 blocked by cadence window" + + # ---- turn 7: fires but silent failure (empty dialectic) ---- + provider.on_turn_start(7, "and then what") + provider.queue_prefetch("and then what") # (7-4)≥3 → fires + self._await_thread(provider) + assert mgr.dialectic_query.call_count == 3, "turn 7 fires" + assert provider._last_dialectic_turn == 4, \ + "silent failure must NOT burn the cadence window" + + # ---- turn 8: retries because cadence didn't advance ---- + provider.on_turn_start(8, "try again") + provider.queue_prefetch("try again") # (8-4)≥3 → fires again + self._await_thread(provider) + assert mgr.dialectic_query.call_count == 4, \ + "turn 8 retries because turn 7's empty result didn't advance cadence" + assert provider._last_dialectic_turn == 8, "retry success advances" + + # ---- session end: flush messages ---- + provider.on_session_end([]) + mgr.flush_all.assert_called() + + +class TestReasoningHeuristic: + """Restored char-count heuristic for auto-injected dialectic reasoning level. + + Pre-9a0ab34c behavior: scale base up by query length, capped at + reasoning_level_cap. 'max' is reserved for explicit tool-path selection. + """ + + @staticmethod + def _make_provider(cfg_extra=None): + from unittest.mock import patch, MagicMock + from plugins.memory.honcho.client import HonchoClientConfig + + defaults = dict( + api_key="test-key", enabled=True, recall_mode="hybrid", + dialectic_reasoning_level="low", reasoning_heuristic=True, + reasoning_level_cap="high", + ) + if cfg_extra: + defaults.update(cfg_extra) + cfg = HonchoClientConfig(**defaults) + provider = HonchoMemoryProvider() + mock_manager = MagicMock() + mock_manager.get_or_create.return_value = MagicMock(messages=[]) + with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ + patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ + patch("hermes_constants.get_hermes_home", return_value=MagicMock()): + provider.initialize(session_id="test-heuristic") + _settle_prewarm(provider) + return provider + + def test_short_query_stays_at_base(self): + p = self._make_provider() + assert p._apply_reasoning_heuristic("low", "hey") == "low" + + def test_medium_query_bumps_one_level(self): + p = self._make_provider() + q = "x" * 150 + assert p._apply_reasoning_heuristic("low", q) == "medium" + + def test_long_query_bumps_two_levels(self): + p = self._make_provider() + q = "x" * 500 + assert p._apply_reasoning_heuristic("low", q) == "high" + + def test_bump_respects_cap(self): + p = self._make_provider(cfg_extra={"reasoning_level_cap": "medium"}) + q = "x" * 500 # would hit 'high' without the cap + assert p._apply_reasoning_heuristic("low", q) == "medium" + + def test_max_never_auto_selected_with_default_cap(self): + p = self._make_provider(cfg_extra={"dialectic_reasoning_level": "high"}) + q = "x" * 500 # base=high, bump would push to 'max' + assert p._apply_reasoning_heuristic("high", q) == "high" + + def test_heuristic_disabled_returns_base(self): + p = self._make_provider(cfg_extra={"reasoning_heuristic": False}) + q = "x" * 500 + assert p._apply_reasoning_heuristic("low", q) == "low" + + def test_resolve_pass_level_applies_heuristic_at_base_mapping(self): + """Depth=1, pass 0 maps to 'base' → heuristic applies.""" + p = self._make_provider() + q = "x" * 150 + assert p._resolve_pass_level(0, query=q) == "medium" + + def test_resolve_pass_level_does_not_touch_explicit_per_pass(self): + """dialecticDepthLevels wins absolutely — no heuristic scaling.""" + p = self._make_provider(cfg_extra={"dialectic_depth_levels": ["minimal"]}) + q = "x" * 500 # heuristic would otherwise bump to 'high' + assert p._resolve_pass_level(0, query=q) == "minimal" + + def test_resolve_pass_level_does_not_touch_lighter_passes(self): + """Depth 3 pass 0 is hardcoded 'minimal' — heuristic must not bump it.""" + p = self._make_provider(cfg_extra={"dialectic_depth": 3}) + q = "x" * 500 + assert p._resolve_pass_level(0, query=q) == "minimal" + # But the 'base' pass (idx 1 for depth 3) does get heuristic + assert p._resolve_pass_level(1, query=q) == "high" + + # --------------------------------------------------------------------------- # set_peer_card None guard # --------------------------------------------------------------------------- diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index 2040949d2581..906a7c030eb7 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -77,7 +77,7 @@ Cost and depth are controlled by three independent knobs: | Knob | Controls | Default | |------|----------|---------| | `contextCadence` | Turns between `context()` API calls (base layer refresh) | `1` | -| `dialecticCadence` | Turns between `peer.chat()` LLM calls (dialectic layer refresh) | `3` | +| `dialecticCadence` | Turns between `peer.chat()` LLM calls (dialectic layer refresh) | `1` | | `dialecticDepth` | Number of `.chat()` passes per dialectic invocation (1–3) | `1` | These are orthogonal — you can have frequent context refreshes with infrequent dialectic, or deep multi-pass dialectic at low frequency. Example: `contextCadence: 1, dialecticCadence: 5, dialecticDepth: 2` refreshes base context every turn, runs dialectic every 5 turns, and each dialectic run makes 2 passes. @@ -104,7 +104,7 @@ Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho |-----|---------|-------------| | `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Set to an integer (e.g. 1200) to cap. Truncates at word boundaries | | `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) | -| `dialecticCadence` | `3` | Minimum turns between `peer.chat()` LLM calls (dialectic layer). In `tools` mode, irrelevant — model calls explicitly | +| `dialecticCadence` | `1` | Minimum turns between `peer.chat()` LLM calls (dialectic layer). In `tools` mode, irrelevant — model calls explicitly | | `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped to 1–3 | | `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `["minimal", "low", "medium"]`. Overrides proportional defaults | | `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` | diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index f571c7d48f15..181f30f7fa22 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -82,7 +82,7 @@ hermes memory setup # select "honcho" | `workspace` | host key | Shared workspace ID | | `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Truncates at word boundaries | | `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) | -| `dialecticCadence` | `3` | Minimum turns between `peer.chat()` LLM calls. Only applies to `hybrid`/`context` modes | +| `dialecticCadence` | `1` | Minimum turns between `peer.chat()` LLM calls. Only applies to `hybrid`/`context` modes | | `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped 1–3. Pass 0: cold/warm prompt, pass 1: self-audit, pass 2: reconciliation | | `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `["minimal", "low", "medium"]`. Overrides proportional defaults | | `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` | @@ -181,7 +181,7 @@ This inherits settings from the default `hermes` host block and creates new AI p }, "dialecticReasoningLevel": "low", "dialecticDynamic": true, - "dialecticCadence": 3, + "dialecticCadence": 1, "dialecticDepth": 1, "dialecticMaxChars": 600, "contextCadence": 1, From a7a38228fa21ecc2dd7cce807c94c1e14bcce26a Mon Sep 17 00:00:00 2001 From: Erosika Date: Sat, 18 Apr 2026 11:01:45 -0400 Subject: [PATCH 2/7] chore(honcho): drop docs from PR scope, scrub commentary - Revert website/docs and SKILL.md changes; docs unification handled separately - Scrub commit/PR refs and process narration from code comments and test docstrings (no behavior change) --- .../autonomous-ai-agents/honcho/SKILL.md | 6 ++-- plugins/memory/honcho/__init__.py | 27 ++++++-------- plugins/memory/honcho/cli.py | 2 +- plugins/memory/honcho/client.py | 10 +++--- tests/honcho_plugin/test_session.py | 35 ++++++------------- website/docs/user-guide/features/honcho.md | 4 +-- .../user-guide/features/memory-providers.md | 4 +-- 7 files changed, 33 insertions(+), 55 deletions(-) diff --git a/optional-skills/autonomous-ai-agents/honcho/SKILL.md b/optional-skills/autonomous-ai-agents/honcho/SKILL.md index 5d03a549858a..c60d2c63561c 100644 --- a/optional-skills/autonomous-ai-agents/honcho/SKILL.md +++ b/optional-skills/autonomous-ai-agents/honcho/SKILL.md @@ -145,10 +145,10 @@ Controls **how often** dialectic and context calls happen. | Key | Default | Description | |-----|---------|-------------| | `contextCadence` | `1` | Min turns between context API calls | -| `dialecticCadence` | `1` | Min turns between dialectic API calls | +| `dialecticCadence` | `3` | Min turns between dialectic API calls | | `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` for base context injection | -Higher cadence values reduce API calls and cost. `dialecticCadence: 1` (default) fires every turn; set to `3` or higher to throttle for cost. +Higher cadence values reduce API calls and cost. `dialecticCadence: 3` (default) means the dialectic engine fires at most every 3rd turn. ### Depth (how many) @@ -368,7 +368,7 @@ Config file: `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.jso | `contextTokens` | uncapped | Max tokens for the combined base context injection (summary + representation + card). Opt-in cap — omit to leave uncapped, set to an integer to bound injection size. | | `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` | | `contextCadence` | `1` | Min turns between context API calls | -| `dialecticCadence` | `1` | Min turns between dialectic LLM calls | +| `dialecticCadence` | `3` | Min turns between dialectic LLM calls | The `contextTokens` budget is enforced at injection time. If the session summary + representation + card exceed the budget, Honcho trims the summary first, then the representation, preserving the card. This prevents context blowup in long sessions. diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index ac0f60279a6c..51345b8e9211 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -393,13 +393,10 @@ def _do_session_init(self, cfg, session_id: str, **kwargs) -> None: logger.debug("Honcho memory file migration skipped: %s", e) # ----- B7: Pre-warming at init ----- - # Context prewarm: warms peer.context() cache (base layer), consumed - # via pop_context_result() in prefetch(). - # Dialectic prewarm: fires a depth-aware cycle against the plugin's - # own _prefetch_result so turn 1 can consume it directly. Without this - # the first-turn sync path pays for a duplicate .chat() — and at - # depth>1 a single-pass session-start dialectic often returns weak - # output that multi-pass audit/reconciliation is meant to catch. + # Context prewarm warms peer.context() (base layer), consumed via + # pop_context_result() in prefetch(). Dialectic prewarm runs the + # full configured depth and writes into _prefetch_result so turn 1 + # consumes the result directly. if self._recall_mode in ("context", "hybrid"): try: self._manager.prefetch_context(self._session_key) @@ -555,8 +552,7 @@ def prefetch(self, query: str, *, session_id: str = "") -> str: if self._injection_frequency == "first-turn" and self._turn_count > 1: return "" - # Skip trivial prompts — "ok", "yes", slash commands carry no semantic signal, - # so injecting user context there just burns tokens and can derail the reply. + # Trivial prompts ("ok", "yes", slash commands) carry no semantic signal. if self._is_trivial_prompt(query): return "" @@ -619,8 +615,8 @@ def _run_first_turn() -> None: if r and r.strip(): with self._prefetch_lock: self._prefetch_result = r - # Only advance cadence on a non-empty result so failures - # don't burn a 3-turn cooldown on nothing. + # Advance cadence only on a non-empty result so the next + # turn retries when the call returned nothing. self._last_dialectic_turn = _fired_at self._prefetch_thread = threading.Thread( @@ -711,9 +707,8 @@ def queue_prefetch(self, query: str, *, session_id: str = "") -> None: self._dialectic_cadence, self._turn_count - self._last_dialectic_turn) return - # Advance cadence only on a non-empty result — otherwise a silent failure - # (empty dialectic, transient API error) would burn the full cadence window - # before the next retry, making it look like dialectic "never fires again". + # Cadence advances only on a non-empty result so empty returns + # (transient API error, sparse representation) retry next turn. _fired_at = self._turn_count def _run(): @@ -751,9 +746,7 @@ def _run(): _LEVEL_ORDER = ("minimal", "low", "medium", "high", "max") - # Reasoning-level heuristic thresholds (restored from pre-9a0ab34c behavior). - # Promoted to class constants so tests can override without widening the - # config surface. Bump to config fields only if real use shows they're needed. + # Char-count thresholds for the query-length reasoning heuristic. _HEURISTIC_LENGTH_MEDIUM = 120 _HEURISTIC_LENGTH_HIGH = 400 diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 478bf39d8a6f..5cd25bfbab30 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -463,7 +463,7 @@ def cmd_setup(args) -> None: current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "1") print("\n Dialectic cadence:") print(" How often Honcho rebuilds its user model (LLM call on Honcho backend).") - print(" 1 = every turn (default), 3+ = sparse (cost-saving).") + print(" 1 = every turn (default), 3+ = sparse.") new_dialectic = _prompt("Dialectic cadence", default=current_dialectic) try: val = int(new_dialectic) diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 136b1e60dc61..346c2b76e688 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -251,13 +251,11 @@ class HonchoClientConfig: # matching dialectic_depth length. When None, uses proportional defaults # derived from dialectic_reasoning_level. dialectic_depth_levels: list[str] | None = None - # Reasoning-level heuristic for auto-injected dialectic calls. When true, - # scales the base level up on longer queries (restored from pre-#10619 - # behavior; see plugins/memory/honcho/__init__.py for thresholds). - # Never auto-selects a level above reasoning_level_cap. + # When true, the auto-injected dialectic scales reasoning level up on + # longer queries. See HonchoMemoryProvider for thresholds. reasoning_heuristic: bool = True - # Ceiling for heuristic-selected reasoning level. "max" is reserved for - # explicit tool-path selection; default "high" matches the old behavior. + # Ceiling for the heuristic-selected reasoning level. "max" is reserved + # for explicit tool-path selection. reasoning_level_cap: str = "high" # Honcho API limits — configurable for self-hosted instances # Max chars per message sent via add_messages() (Honcho cloud: 25000) diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index b0282b1969c9..83db3f24dc90 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -862,9 +862,7 @@ def _make_provider(cfg_extra=None): return provider def test_default_is_1(self): - """Default dialectic_cadence should be 1 (every turn) — restored from - pre-#10619 behavior to avoid a silent regression on upgrade for users - who never set dialecticCadence explicitly.""" + """Default dialectic_cadence is 1 — fires every turn unless overridden.""" provider = self._make_provider() assert provider._dialectic_cadence == 1 @@ -1112,10 +1110,7 @@ def test_run_dialectic_depth_bails_early_on_strong_signal(self): class TestTrivialPromptHeuristic: - """Trivial prompts ('ok', 'y', slash commands) must short-circuit injection. - - Restored after accidental removal during the two-layer prefetch refactor. - """ + """Trivial prompts ('ok', 'y', slash commands) must short-circuit injection.""" @staticmethod def _make_provider(): @@ -1173,11 +1168,9 @@ def test_queue_prefetch_skips_on_trivial_prompt(self): class TestDialecticCadenceAdvancesOnSuccess: - """Cadence tracker must only advance when the dialectic call actually returned. - - A silent failure (empty result, API blip) used to burn the full cadence window - before retrying — making it look like dialectic 'never fires again'. - """ + """Cadence tracker advances only when the dialectic call returns a + non-empty result. Empty results (transient API error, sparse representation) + must retry on the next eligible turn instead of waiting the full cadence.""" @staticmethod def _make_provider(): @@ -1329,13 +1322,9 @@ def test_turn1_falls_back_to_sync_when_prewarm_missing(self): class TestDialecticLifecycleSmoke: - """End-to-end smoke: walks a realistic multi-turn session through every - behavior we care about — prewarm → turn 1 consume → trivial skip → cadence - fire → silent-failure retry → heuristic bump → session-end flush. - - This is the 'velvet circuit' test: one provider, one flow, one set of - assertions. If the suite above lies about intent, this one catches it. - """ + """End-to-end smoke walking a multi-turn session through prewarm, + turn 1 consume, trivial skip, cadence fire, empty-result retry, + heuristic bump, and session-end flush.""" @staticmethod def _make_provider(cfg_extra=None): @@ -1473,11 +1462,9 @@ def test_full_multi_turn_session(self): class TestReasoningHeuristic: - """Restored char-count heuristic for auto-injected dialectic reasoning level. - - Pre-9a0ab34c behavior: scale base up by query length, capped at - reasoning_level_cap. 'max' is reserved for explicit tool-path selection. - """ + """Char-count heuristic that scales the auto-injected reasoning level by + query length, clamped at reasoning_level_cap. 'max' is reserved for + explicit tool-path selection.""" @staticmethod def _make_provider(cfg_extra=None): diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index 906a7c030eb7..2040949d2581 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -77,7 +77,7 @@ Cost and depth are controlled by three independent knobs: | Knob | Controls | Default | |------|----------|---------| | `contextCadence` | Turns between `context()` API calls (base layer refresh) | `1` | -| `dialecticCadence` | Turns between `peer.chat()` LLM calls (dialectic layer refresh) | `1` | +| `dialecticCadence` | Turns between `peer.chat()` LLM calls (dialectic layer refresh) | `3` | | `dialecticDepth` | Number of `.chat()` passes per dialectic invocation (1–3) | `1` | These are orthogonal — you can have frequent context refreshes with infrequent dialectic, or deep multi-pass dialectic at low frequency. Example: `contextCadence: 1, dialecticCadence: 5, dialecticDepth: 2` refreshes base context every turn, runs dialectic every 5 turns, and each dialectic run makes 2 passes. @@ -104,7 +104,7 @@ Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho |-----|---------|-------------| | `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Set to an integer (e.g. 1200) to cap. Truncates at word boundaries | | `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) | -| `dialecticCadence` | `1` | Minimum turns between `peer.chat()` LLM calls (dialectic layer). In `tools` mode, irrelevant — model calls explicitly | +| `dialecticCadence` | `3` | Minimum turns between `peer.chat()` LLM calls (dialectic layer). In `tools` mode, irrelevant — model calls explicitly | | `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped to 1–3 | | `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `["minimal", "low", "medium"]`. Overrides proportional defaults | | `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` | diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index 181f30f7fa22..f571c7d48f15 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -82,7 +82,7 @@ hermes memory setup # select "honcho" | `workspace` | host key | Shared workspace ID | | `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Truncates at word boundaries | | `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) | -| `dialecticCadence` | `1` | Minimum turns between `peer.chat()` LLM calls. Only applies to `hybrid`/`context` modes | +| `dialecticCadence` | `3` | Minimum turns between `peer.chat()` LLM calls. Only applies to `hybrid`/`context` modes | | `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped 1–3. Pass 0: cold/warm prompt, pass 1: self-audit, pass 2: reconciliation | | `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `["minimal", "low", "medium"]`. Overrides proportional defaults | | `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` | @@ -181,7 +181,7 @@ This inherits settings from the default `hermes` host block and creates new AI p }, "dialecticReasoningLevel": "low", "dialecticDynamic": true, - "dialecticCadence": 1, + "dialecticCadence": 3, "dialecticDepth": 1, "dialecticMaxChars": 600, "contextCadence": 1, From b9c2024aa5d482cff3d9a89e0e6c7fa91ffd3bed Mon Sep 17 00:00:00 2001 From: Erosika Date: Sat, 18 Apr 2026 12:45:04 -0400 Subject: [PATCH 3/7] docs(honcho): wizard cadence default 2, prewarm/depth + observation + multi-peer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cli: setup wizard pre-fills dialecticCadence=2 (code default stays 1 so unset → every turn) - honcho.md: fix stale dialecticCadence default in tables, add Session-Start Prewarm subsection (depth runs at init), add Query-Adaptive Reasoning Level subsection, expand Observation section with directional vs unified semantics and per-peer patterns - memory-providers.md: fix stale default, rename Multi-agent/Profiles to Multi-peer setup, add concrete walkthrough for new profiles and sync, document observation toggles + presets, link to honcho.md - SKILL.md: fix stale defaults, add Depth at session start callout --- .../autonomous-ai-agents/honcho/SKILL.md | 8 ++- plugins/memory/honcho/cli.py | 6 +- website/docs/user-guide/features/honcho.md | 47 ++++++++++++++- .../user-guide/features/memory-providers.md | 59 ++++++++++++++++--- 4 files changed, 103 insertions(+), 17 deletions(-) diff --git a/optional-skills/autonomous-ai-agents/honcho/SKILL.md b/optional-skills/autonomous-ai-agents/honcho/SKILL.md index c60d2c63561c..e79875aa0734 100644 --- a/optional-skills/autonomous-ai-agents/honcho/SKILL.md +++ b/optional-skills/autonomous-ai-agents/honcho/SKILL.md @@ -145,10 +145,10 @@ Controls **how often** dialectic and context calls happen. | Key | Default | Description | |-----|---------|-------------| | `contextCadence` | `1` | Min turns between context API calls | -| `dialecticCadence` | `3` | Min turns between dialectic API calls | +| `dialecticCadence` | `1` (wizard: `2`) | Min turns between dialectic API calls. Unset → every turn; wizard pre-fills `2` | | `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` for base context injection | -Higher cadence values reduce API calls and cost. `dialecticCadence: 3` (default) means the dialectic engine fires at most every 3rd turn. +Higher cadence values fire the dialectic LLM less often. `dialecticCadence: 2` means the engine fires every other turn. Setting it to `1` fires every turn. ### Depth (how many) @@ -180,6 +180,8 @@ If `dialecticDepthLevels` is omitted, rounds use **proportional levels** derived This keeps earlier passes cheap while using full depth on the final synthesis. +**Depth at session start.** The session-start prewarm runs the full configured `dialecticDepth` in the background before turn 1. A single-pass prewarm on a cold peer often returns thin output — multi-pass depth runs the audit/reconcile cycle before the user ever speaks. Turn 1 consumes the prewarm result directly; if prewarm hasn't landed in time, turn 1 falls back to a synchronous call with a bounded timeout. + ### Level (how hard) Controls the **intensity** of each dialectic reasoning round. @@ -368,7 +370,7 @@ Config file: `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.jso | `contextTokens` | uncapped | Max tokens for the combined base context injection (summary + representation + card). Opt-in cap — omit to leave uncapped, set to an integer to bound injection size. | | `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` | | `contextCadence` | `1` | Min turns between context API calls | -| `dialecticCadence` | `3` | Min turns between dialectic LLM calls | +| `dialecticCadence` | `1` (wizard: `2`) | Min turns between dialectic LLM calls | The `contextTokens` budget is enforced at injection time. If the session summary + representation + card exceed the budget, Honcho trims the summary first, then the representation, preserving the card. This prevents context blowup in long sessions. diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 5cd25bfbab30..c73dd66f39e3 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -460,17 +460,17 @@ def cmd_setup(args) -> None: pass # keep current # --- 7b. Dialectic cadence --- - current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "1") + current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "2") print("\n Dialectic cadence:") print(" How often Honcho rebuilds its user model (LLM call on Honcho backend).") - print(" 1 = every turn (default), 3+ = sparse.") + print(" 1 = every turn, 2 = every other turn (wizard default), 3+ = sparse.") new_dialectic = _prompt("Dialectic cadence", default=current_dialectic) try: val = int(new_dialectic) if val >= 1: hermes_host["dialecticCadence"] = val except (ValueError, TypeError): - hermes_host["dialecticCadence"] = 1 + hermes_host["dialecticCadence"] = 2 # --- 8. Session strategy --- current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-session") diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index 2040949d2581..bf4b5c6bc37c 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -77,7 +77,7 @@ Cost and depth are controlled by three independent knobs: | Knob | Controls | Default | |------|----------|---------| | `contextCadence` | Turns between `context()` API calls (base layer refresh) | `1` | -| `dialecticCadence` | Turns between `peer.chat()` LLM calls (dialectic layer refresh) | `3` | +| `dialecticCadence` | Turns between `peer.chat()` LLM calls (dialectic layer refresh) | `1` (code default) / `2` (setup wizard default) | | `dialecticDepth` | Number of `.chat()` passes per dialectic invocation (1–3) | `1` | These are orthogonal — you can have frequent context refreshes with infrequent dialectic, or deep multi-pass dialectic at low frequency. Example: `contextCadence: 1, dialecticCadence: 5, dialecticDepth: 2` refreshes base context every turn, runs dialectic every 5 turns, and each dialectic run makes 2 passes. @@ -94,6 +94,14 @@ Each pass uses a proportional reasoning level (lighter early passes, base level Passes bail out early if the prior pass returned strong signal (long, structured output), so depth 3 doesn't always mean 3 LLM calls. +### Session-Start Prewarm + +On session init, Honcho fires a dialectic call in the background at the full configured `dialecticDepth` and hands the result directly to turn 1's context assembly. A single-pass prewarm on a cold peer often returns thin output — multi-pass depth runs the audit/reconcile cycle before the user ever speaks. If prewarm hasn't landed by turn 1, turn 1 falls back to a synchronous call with a bounded timeout. + +### Query-Adaptive Reasoning Level + +The auto-injected dialectic scales `dialecticReasoningLevel` by query length: +1 level at ≥120 chars, +2 at ≥400, clamped at `reasoningLevelCap` (default `"high"`). Disable with `reasoningHeuristic: false` to pin every auto call to `dialecticReasoningLevel`. `"max"` is reserved for explicit tool-path selection via `honcho_reasoning`. + ## Configuration Options Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho.json` (profile-local). The setup wizard handles this for you. @@ -104,7 +112,7 @@ Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho |-----|---------|-------------| | `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Set to an integer (e.g. 1200) to cap. Truncates at word boundaries | | `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) | -| `dialecticCadence` | `3` | Minimum turns between `peer.chat()` LLM calls (dialectic layer). In `tools` mode, irrelevant — model calls explicitly | +| `dialecticCadence` | `1` (wizard sets `2`) | Minimum turns between `peer.chat()` LLM calls (dialectic layer). Code default fires every turn when the key is unset; the setup wizard pre-fills `2`. In `tools` mode, irrelevant — model calls explicitly | | `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped to 1–3 | | `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `["minimal", "low", "medium"]`. Overrides proportional defaults | | `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` | @@ -142,6 +150,41 @@ Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho In `tools` mode, the model is fully in control — it calls `honcho_reasoning` when it wants, at whatever `reasoning_level` it picks. Cadence and budget settings only apply to modes with auto-injection (`hybrid` and `context`). +## Observation (Directional vs. Unified) + +Honcho models a conversation as peers exchanging messages. Each peer has two observation toggles that map 1:1 to Honcho's `SessionPeerConfig`: + +| Toggle | Effect | +|--------|--------| +| `observeMe` | Honcho builds a representation of this peer from its own messages | +| `observeOthers` | This peer observes the other peer's messages (feeds cross-peer reasoning) | + +Two peers × two toggles = four flags. `observationMode` is a shorthand preset: + +| Preset | User flags | AI flags | Semantics | +|--------|-----------|----------|-----------| +| `"directional"` (default) | me: on, others: on | me: on, others: on | Full mutual observation. Enables cross-peer dialectic — "what does the AI know about the user, based on what the user said and the AI replied." | +| `"unified"` | me: on, others: off | me: off, others: on | Shared-pool semantics — the AI observes the user's messages only, the user peer only self-models. Single-observer pool. | + +Override the preset with an explicit `observation` block for per-peer control: + +```json +"observation": { + "user": { "observeMe": true, "observeOthers": true }, + "ai": { "observeMe": true, "observeOthers": false } +} +``` + +Common patterns: + +| Intent | Config | +|--------|--------| +| Full observation (most users) | `"observationMode": "directional"` | +| AI shouldn't re-model the user from its own replies | `"ai": {"observeMe": true, "observeOthers": false}` | +| Strong persona the AI peer shouldn't update from self-observation | `"ai": {"observeMe": false, "observeOthers": true}` | + +Server-side toggles set via the Honcho dashboard win over local defaults — Hermes syncs them back at session init. + ## Tools When Honcho is active as the memory provider, five tools become available: diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index f571c7d48f15..b2469a13ee37 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -82,7 +82,7 @@ hermes memory setup # select "honcho" | `workspace` | host key | Shared workspace ID | | `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Truncates at word boundaries | | `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) | -| `dialecticCadence` | `3` | Minimum turns between `peer.chat()` LLM calls. Only applies to `hybrid`/`context` modes | +| `dialecticCadence` | `1` (wizard sets `2`) | Minimum turns between `peer.chat()` LLM calls. Unset → every turn; wizard pre-fills `2`. Only applies to `hybrid`/`context` modes | | `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped 1–3. Pass 0: cold/warm prompt, pass 1: self-audit, pass 2: reconciliation | | `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `["minimal", "low", "medium"]`. Overrides proportional defaults | | `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` | @@ -140,23 +140,64 @@ hermes memory setup # select "honcho" If you previously used `hermes honcho setup`, your config and all server-side data are intact. Just re-enable through the setup wizard again or manually set `memory.provider: honcho` to reactivate via the new system. ::: -**Multi-agent / Profiles:** +**Multi-peer setup:** -Each Hermes profile gets its own Honcho AI peer while sharing the same workspace -- all profiles see the same user representation, but each agent builds its own identity and observations. +Honcho models conversations as peers exchanging messages — one user peer plus one AI peer per Hermes profile, all sharing a workspace. The workspace is the shared environment: the user peer is global across profiles, each AI peer is its own identity. Every AI peer builds an independent representation / card from its own observations, so a `coder` profile stays code-oriented while a `writer` profile stays editorial against the same user. + +The mapping: + +| Concept | What it is | +|---------|-----------| +| **Workspace** | Shared environment. All Hermes profiles under one workspace see the same user identity. | +| **User peer** (`peerName`) | The human. Shared across profiles in the workspace. | +| **AI peer** (`aiPeer`) | One per Hermes profile. Host key `hermes` → default; `hermes.` for others. | +| **Observation** | Per-peer toggles controlling what Honcho models from whose messages. `directional` (default, all four on) or `unified` (single-observer pool). | + +### New profile, fresh Honcho peer ```bash -hermes profile create coder --clone # creates honcho peer "coder", inherits config from default +hermes profile create coder --clone ``` -What `--clone` does: creates a `hermes.coder` host block in `honcho.json` with `aiPeer: "coder"`, shared `workspace`, inherited `peerName`, `recallMode`, `writeFrequency`, `observation`, etc. The peer is eagerly created in Honcho so it exists before first message. +`--clone` creates a `hermes.coder` host block in `honcho.json` with `aiPeer: "coder"`, shared `workspace`, inherited `peerName`, `recallMode`, `writeFrequency`, `observation`, etc. The AI peer is eagerly created in Honcho so it exists before the first message. -For profiles created before Honcho was set up: +### Existing profiles, backfill Honcho peers ```bash -hermes honcho sync # scans all profiles, creates host blocks for any missing ones +hermes honcho sync +``` + +Scans every Hermes profile, creates host blocks for any profile without one, inherits settings from the default `hermes` block, and creates the new AI peers eagerly. Idempotent — skips profiles that already have a host block. + +### Per-profile observation + +Each host block can override the observation config independently. Example: a code-focused profile where the AI peer observes the user but doesn't self-model: + +```json +"hermes.coder": { + "aiPeer": "coder", + "observation": { + "user": { "observeMe": true, "observeOthers": true }, + "ai": { "observeMe": false, "observeOthers": true } + } +} ``` -This inherits settings from the default `hermes` host block and creates new AI peers for each profile. Idempotent -- skips profiles that already have a host block. +**Observation toggles (one set per peer):** + +| Toggle | Effect | +|--------|--------| +| `observeMe` | Honcho builds a representation of this peer from its own messages | +| `observeOthers` | This peer observes the other peer's messages (feeds cross-peer reasoning) | + +Presets via `observationMode`: + +- **`"directional"`** (default) — all four flags on. Full mutual observation; enables cross-peer dialectic. +- **`"unified"`** — user `observeMe: true`, AI `observeOthers: true`, rest false. Single-observer pool; AI models the user but not itself, user peer only self-models. + +Server-side toggles set via the [Honcho dashboard](https://app.honcho.dev) win over local defaults — synced back at session init. + +See the [Honcho page](./honcho.md#observation-directional-vs-unified) for the full observation reference.
Full honcho.json example (multi-profile) @@ -181,7 +222,7 @@ This inherits settings from the default `hermes` host block and creates new AI p }, "dialecticReasoningLevel": "low", "dialecticDynamic": true, - "dialecticCadence": 3, + "dialecticCadence": 2, "dialecticDepth": 1, "dialecticMaxChars": 600, "contextCadence": 1, From df844adaf62b8d4e2375b7603c61d17e73dec679 Mon Sep 17 00:00:00 2001 From: Erosika Date: Sat, 18 Apr 2026 13:07:09 -0400 Subject: [PATCH 4/7] =?UTF-8?q?feat(honcho):=20dialectic=20liveness=20?= =?UTF-8?q?=E2=80=94=20stale-thread=20watchdog,=20stale-result=20discard,?= =?UTF-8?q?=20empty-streak=20backoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardens the dialectic lifecycle against three failure modes that could leave the prefetch pipeline stuck or injecting stale content: - Stale-thread watchdog: _thread_is_live() treats any prefetch thread older than timeout × 2.0 as dead. A hung Honcho call can no longer block subsequent fires indefinitely. - Stale-result discard: pending _prefetch_result is tagged with its fire turn. prefetch() discards the result if more than cadence × 2 turns passed before a consumer read it (e.g. a run of trivial-prompt turns between fire and read). - Empty-streak backoff: consecutive empty dialectic returns widen the effective cadence (dialectic_cadence + streak, capped at cadence × 8). A healthy fire resets the streak. Prevents the plugin from hammering the backend every turn when the peer graph is cold. - liveness_snapshot() on the provider exposes current turn, last fire, pending fire-at, empty streak, effective cadence, and thread status for in-process diagnostics. - system_prompt_block: nudge the model that honcho_reasoning accepts reasoning_level minimal/low/medium/high/max per call. - hermes honcho status: surface base reasoning level, cap, and heuristic toggle so config drift is visible at a glance. Tests: 550 passed. - TestDialecticLiveness (8 tests): stale-thread recovery, stale-result discard, fresh-result retention, backoff widening, backoff ceiling, streak reset on success, streak increment on empty, snapshot shape. - Existing TestDialecticCadenceAdvancesOnSuccess::test_in_flight_thread_is_not_stacked updated to set _prefetch_thread_started_at so it tests the fresh-thread-blocks branch (stale path covered separately). - test_cli TestCmdStatus fake updated with the new config attrs surfaced in the status block. --- plugins/memory/honcho/__init__.py | 120 +++++++++++++++++++-- plugins/memory/honcho/cli.py | 3 + tests/honcho_plugin/test_cli.py | 3 + tests/honcho_plugin/test_session.py | 156 +++++++++++++++++++++++++++- 4 files changed, 266 insertions(+), 16 deletions(-) diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index 51345b8e9211..68fa868855c0 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -19,6 +19,7 @@ import logging import re import threading +import time from typing import Any, Dict, List, Optional from agent.memory_provider import MemoryProvider @@ -214,6 +215,11 @@ def __init__(self): self._last_context_turn = -999 self._last_dialectic_turn = -999 + # Liveness + observability state + self._prefetch_thread_started_at: float = 0.0 # monotonic ts of current thread + self._prefetch_result_fired_at: int = -999 # turn the pending result was fired at + self._dialectic_empty_streak: int = 0 # consecutive empty returns + # Port #1957: lazy session init for tools-only mode self._session_initialized = False self._lazy_init_kwargs: Optional[dict] = None @@ -413,13 +419,19 @@ def _prewarm_dialectic() -> None: r = self._run_dialectic_depth(_prewarm_query) except Exception as exc: logger.debug("Honcho dialectic prewarm failed: %s", exc) + self._dialectic_empty_streak += 1 return if r and r.strip(): with self._prefetch_lock: self._prefetch_result = r + self._prefetch_result_fired_at = 0 # Treat prewarm as turn 0 so cadence gating starts clean. self._last_dialectic_turn = 0 + self._dialectic_empty_streak = 0 + else: + self._dialectic_empty_streak += 1 + self._prefetch_thread_started_at = time.monotonic() self._prefetch_thread = threading.Thread( target=_prewarm_dialectic, daemon=True, name="honcho-prewarm-dialectic" ) @@ -513,7 +525,8 @@ def system_prompt_block(self) -> str: "# Honcho Memory\n" "Active (tools-only mode). Use honcho_profile for a quick factual snapshot, " "honcho_search for raw excerpts, honcho_context for raw peer context, " - "honcho_reasoning for synthesized answers, " + "honcho_reasoning for synthesized answers (pass reasoning_level " + "minimal/low/medium/high/max — you pick the depth per call), " "honcho_conclude to save facts about the user. " "No automatic context injection — you must use tools to access memory." ) @@ -523,7 +536,8 @@ def system_prompt_block(self) -> str: "Active (hybrid mode). Relevant context is auto-injected AND memory tools are available. " "Use honcho_profile for a quick factual snapshot, " "honcho_search for raw excerpts, honcho_context for raw peer context, " - "honcho_reasoning for synthesized answers, " + "honcho_reasoning for synthesized answers (pass reasoning_level " + "minimal/low/medium/high/max — you pick the depth per call), " "honcho_conclude to save facts about the user." ) @@ -611,14 +625,20 @@ def _run_first_turn() -> None: r = self._run_dialectic_depth(query) except Exception as exc: logger.debug("Honcho first-turn dialectic failed: %s", exc) + self._dialectic_empty_streak += 1 return if r and r.strip(): with self._prefetch_lock: self._prefetch_result = r + self._prefetch_result_fired_at = _fired_at # Advance cadence only on a non-empty result so the next # turn retries when the call returned nothing. self._last_dialectic_turn = _fired_at + self._dialectic_empty_streak = 0 + else: + self._dialectic_empty_streak += 1 + self._prefetch_thread_started_at = time.monotonic() self._prefetch_thread = threading.Thread( target=_run_first_turn, daemon=True, name="honcho-prefetch-first" ) @@ -635,7 +655,21 @@ def _run_first_turn() -> None: self._prefetch_thread.join(timeout=3.0) with self._prefetch_lock: dialectic_result = self._prefetch_result + fired_at = self._prefetch_result_fired_at self._prefetch_result = "" + self._prefetch_result_fired_at = -999 + + # Discard stale pending results: if the fire happened more than + # cadence × multiplier turns ago (e.g. a run of trivial-prompt turns + # passed without consumption), the content likely no longer tracks + # the current conversational pivot. + stale_limit = self._dialectic_cadence * self._STALE_RESULT_MULTIPLIER + if dialectic_result and fired_at >= 0 and (self._turn_count - fired_at) > stale_limit: + logger.debug( + "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) @@ -693,18 +727,23 @@ def queue_prefetch(self, query: str, *, session_id: str = "") -> None: logger.debug("Honcho context prefetch failed: %s", e) # ----- Dialectic prefetch (supplement layer) ----- - # Guard against thread pile-up: if a prior dialectic is still in flight, - # let it finish instead of stacking races on _prefetch_result. - if self._prefetch_thread and self._prefetch_thread.is_alive(): + # Thread-alive guard with stale-thread recovery: a hung Honcho call + # older than timeout × multiplier is treated as dead so it can't + # block subsequent fires. + if self._thread_is_live(): logger.debug("Honcho dialectic prefetch skipped: prior thread still running") return - # B5: cadence check — skip if too soon since last *successful* dialectic call. - # The gate applies uniformly (including cadence=1): "every turn" means once - # per turn, not twice on the same turn when first-turn sync already fired. - if (self._turn_count - self._last_dialectic_turn) < self._dialectic_cadence: - logger.debug("Honcho dialectic prefetch skipped: cadence %d, turns since last: %d", - self._dialectic_cadence, self._turn_count - self._last_dialectic_turn) + # Cadence gate, widened by the empty-streak backoff so a persistently + # silent backend doesn't retry every turn forever. + effective = self._effective_cadence() + if (self._turn_count - self._last_dialectic_turn) < effective: + logger.debug( + "Honcho dialectic prefetch skipped: effective cadence %d " + "(base %d, empty streak %d), turns since last: %d", + effective, self._dialectic_cadence, self._dialectic_empty_streak, + self._turn_count - self._last_dialectic_turn, + ) return # Cadence advances only on a non-empty result so empty returns @@ -716,12 +755,18 @@ def _run(): result = self._run_dialectic_depth(query) except Exception as e: logger.debug("Honcho prefetch failed: %s", e) + self._dialectic_empty_streak += 1 return if result and result.strip(): with self._prefetch_lock: self._prefetch_result = result + self._prefetch_result_fired_at = _fired_at self._last_dialectic_turn = _fired_at + self._dialectic_empty_streak = 0 + else: + self._dialectic_empty_streak += 1 + self._prefetch_thread_started_at = time.monotonic() self._prefetch_thread = threading.Thread( target=_run, daemon=True, name="honcho-prefetch" ) @@ -750,6 +795,59 @@ def _run(): _HEURISTIC_LENGTH_MEDIUM = 120 _HEURISTIC_LENGTH_HIGH = 400 + # Liveness constants. A thread older than timeout × multiplier is treated + # as dead so a hung Honcho call can't block future retries indefinitely. + _STALE_THREAD_MULTIPLIER = 2.0 + # Pending result whose fire-turn is older than cadence × multiplier is + # discarded on read so we don't inject context for a stale conversational + # pivot after a gap of trivial-prompt turns. + _STALE_RESULT_MULTIPLIER = 2 + # Cap on the empty-streak backoff so a persistently silent backend + # eventually settles on a ceiling instead of unbounded widening. + _BACKOFF_MAX = 8 + + def _thread_is_live(self) -> bool: + """Thread-alive guard that treats threads older than the stale + threshold as dead, so a hung Honcho request can't block new fires.""" + if not self._prefetch_thread or not self._prefetch_thread.is_alive(): + return False + timeout = (self._config.timeout if self._config and self._config.timeout else 8.0) + age = time.monotonic() - self._prefetch_thread_started_at + if age > timeout * self._STALE_THREAD_MULTIPLIER: + logger.debug( + "Honcho prefetch thread age %.1fs exceeds stale threshold " + "%.1fs — treating as dead", age, timeout * self._STALE_THREAD_MULTIPLIER, + ) + return False + return True + + def _effective_cadence(self) -> int: + """Cadence plus empty-streak backoff, capped at _BACKOFF_MAX × base.""" + if self._dialectic_empty_streak <= 0: + return self._dialectic_cadence + widened = self._dialectic_cadence + self._dialectic_empty_streak + ceiling = self._dialectic_cadence * self._BACKOFF_MAX + return min(widened, ceiling) + + def liveness_snapshot(self) -> dict: + """In-process snapshot of dialectic liveness state for diagnostics. + + Returns current turn, last successful dialectic turn, pending-result + fire turn, empty streak, effective cadence, and thread status. + """ + thread_age = None + if self._prefetch_thread and self._prefetch_thread.is_alive(): + thread_age = time.monotonic() - self._prefetch_thread_started_at + return { + "turn_count": self._turn_count, + "last_dialectic_turn": self._last_dialectic_turn, + "pending_result_fired_at": self._prefetch_result_fired_at, + "empty_streak": self._dialectic_empty_streak, + "effective_cadence": self._effective_cadence(), + "thread_alive": thread_age is not None, + "thread_age_seconds": thread_age, + } + def _apply_reasoning_heuristic(self, base: str, query: str) -> str: """Scale `base` up by query length, clamped at reasoning_level_cap. diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index c73dd66f39e3..eb21c48eaa8f 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -638,6 +638,9 @@ def cmd_status(args) -> None: raw = getattr(hcfg, "raw", None) or {} dialectic_cadence = raw.get("dialecticCadence") or 1 print(f" Dialectic cad: every {dialectic_cadence} turn{'s' if dialectic_cadence != 1 else ''}") + reasoning_cap = raw.get("reasoningLevelCap") or hcfg.reasoning_level_cap + heuristic_on = "on" if hcfg.reasoning_heuristic else "off" + print(f" Reasoning: base={hcfg.dialectic_reasoning_level}, cap={reasoning_cap}, heuristic={heuristic_on}") print(f" Observation: user(me={hcfg.user_observe_me},others={hcfg.user_observe_others}) ai(me={hcfg.ai_observe_me},others={hcfg.ai_observe_others})") print(f" Write freq: {hcfg.write_frequency}") diff --git a/tests/honcho_plugin/test_cli.py b/tests/honcho_plugin/test_cli.py index 006d687dc1dc..a6fc39ea7c01 100644 --- a/tests/honcho_plugin/test_cli.py +++ b/tests/honcho_plugin/test_cli.py @@ -26,6 +26,9 @@ class FakeConfig: write_frequency = "async" session_strategy = "per-session" context_tokens = 800 + dialectic_reasoning_level = "low" + reasoning_level_cap = "high" + reasoning_heuristic = True def resolve_session_name(self): return "hermes" diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index 83db3f24dc90..37f54b541038 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -823,8 +823,11 @@ def _settle_prewarm(provider): provider._prefetch_thread.join(timeout=3.0) with provider._prefetch_lock: provider._prefetch_result = "" + provider._prefetch_result_fired_at = -999 provider._prefetch_thread = None + provider._prefetch_thread_started_at = 0.0 provider._last_dialectic_turn = -999 + provider._dialectic_empty_streak = 0 if getattr(provider, "_manager", None) is not None: try: provider._manager.dialectic_query.reset_mock() @@ -1227,26 +1230,28 @@ def test_non_empty_dialectic_result_advances_cadence(self): def test_in_flight_thread_is_not_stacked(self): import threading as _threading + import time as _time provider = self._make_provider() provider._session_key = "test" provider._turn_count = 10 provider._last_dialectic_turn = 0 - # Simulate a prior thread still running + # Simulate a prior thread still running (fresh, not stale) hold = _threading.Event() def _block(): hold.wait(timeout=5.0) - stale = _threading.Thread(target=_block, daemon=True) - stale.start() - provider._prefetch_thread = stale + fresh = _threading.Thread(target=_block, daemon=True) + fresh.start() + provider._prefetch_thread = fresh + provider._prefetch_thread_started_at = _time.monotonic() # fresh start provider.queue_prefetch("hello") # Should have short-circuited — no new dialectic call assert provider._manager.dialectic_query.call_count == 0 hold.set() - stale.join(timeout=2.0) + fresh.join(timeout=2.0) class TestSessionStartDialecticPrewarm: @@ -1321,6 +1326,147 @@ def test_turn1_falls_back_to_sync_when_prewarm_missing(self): assert p._manager.dialectic_query.call_count == 1 +class TestDialecticLiveness: + """Liveness + observability: stale-thread recovery, stale-result discard, + empty-streak backoff, and the snapshot method used for diagnostics.""" + + @staticmethod + def _make_provider(cfg_extra=None): + from unittest.mock import patch, MagicMock + from plugins.memory.honcho.client import HonchoClientConfig + + defaults = dict(api_key="test-key", enabled=True, recall_mode="hybrid", timeout=2.0) + if cfg_extra: + defaults.update(cfg_extra) + cfg = HonchoClientConfig(**defaults) + provider = HonchoMemoryProvider() + mock_manager = MagicMock() + mock_manager.get_or_create.return_value = MagicMock(messages=[]) + mock_manager.get_prefetch_context.return_value = None + mock_manager.pop_context_result.return_value = None + mock_manager.dialectic_query.return_value = "" # default: silent + + with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ + patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ + patch("hermes_constants.get_hermes_home", return_value=MagicMock()): + provider.initialize(session_id="test-liveness") + _settle_prewarm(provider) + return provider + + def test_stale_thread_is_treated_as_dead(self): + """A thread older than timeout × multiplier no longer blocks new fires.""" + import threading as _threading + p = self._make_provider() + p._session_key = "test" + p._turn_count = 10 + p._last_dialectic_turn = 0 + p._manager.dialectic_query.return_value = "fresh synthesis" + + # Plant an alive thread with an old timestamp (stale) + hold = _threading.Event() + stuck = _threading.Thread(target=lambda: hold.wait(timeout=10.0), daemon=True) + stuck.start() + p._prefetch_thread = stuck + # timeout=2.0, multiplier=2.0, so anything older than 4s is stale + p._prefetch_thread_started_at = 0.0 # very old (1970 monotonic baseline) + + p.queue_prefetch("hello") + # New thread should have been spawned since stuck one is stale + assert p._prefetch_thread is not stuck, "stale thread must be recycled" + if p._prefetch_thread: + p._prefetch_thread.join(timeout=2.0) + assert p._manager.dialectic_query.call_count == 1 + hold.set() + stuck.join(timeout=2.0) + + 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._session_key = "test" + p._base_context_cache = "base ctx" + with p._prefetch_lock: + p._prefetch_result = "ancient synthesis" + p._prefetch_result_fired_at = 1 + # cadence=2, multiplier=2 → stale after 4 turns since fire + p._turn_count = 10 + p._last_dialectic_turn = 1 # prevents sync first-turn path + + result = p.prefetch("what's new") + assert "ancient synthesis" not in result, "stale pending must be discarded" + # Cache slot cleared + with p._prefetch_lock: + assert p._prefetch_result == "" + assert p._prefetch_result_fired_at == -999 + + 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._session_key = "test" + p._base_context_cache = "" + with p._prefetch_lock: + p._prefetch_result = "recent synthesis" + p._prefetch_result_fired_at = 8 + p._turn_count = 9 # 1 turn since fire, well within cadence × 2 = 6 + p._last_dialectic_turn = 8 + + result = p.prefetch("what's new") + assert "recent synthesis" in result + + 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._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._dialectic_empty_streak = 100 + # cadence=2, ceiling = 2 × 8 = 16 + assert p._effective_cadence() == 16 + + 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._session_key = "test" + p._dialectic_empty_streak = 5 + p._turn_count = 10 + p._last_dialectic_turn = 0 + p._manager.dialectic_query.return_value = "real output" + + p.queue_prefetch("hello") + if p._prefetch_thread: + p._prefetch_thread.join(timeout=2.0) + assert p._dialectic_empty_streak == 0 + assert p._last_dialectic_turn == 10 + + def test_empty_result_increments_streak(self): + p = self._make_provider(cfg_extra={"raw": {"dialecticCadence": 1}}) + p._session_key = "test" + p._turn_count = 5 + p._last_dialectic_turn = 0 + p._manager.dialectic_query.return_value = "" # empty + + p.queue_prefetch("hello") + if p._prefetch_thread: + p._prefetch_thread.join(timeout=2.0) + assert p._dialectic_empty_streak == 1 + assert p._last_dialectic_turn == 0 # cadence not advanced + + def test_liveness_snapshot_shape(self): + p = self._make_provider() + snap = p.liveness_snapshot() + for key in ( + "turn_count", "last_dialectic_turn", "pending_result_fired_at", + "empty_streak", "effective_cadence", "thread_alive", "thread_age_seconds", + ): + assert key in snap + + class TestDialecticLifecycleSmoke: """End-to-end smoke walking a multi-turn session through prewarm, turn 1 consume, trivial skip, cadence fire, empty-result retry, From 39807f5ff911a86b292d559ee7b49d8c2e5d1e9b Mon Sep 17 00:00:00 2001 From: Erosika Date: Sat, 18 Apr 2026 13:17:44 -0400 Subject: [PATCH 5/7] test(honcho): drop two first-turn tests subsumed by prewarm + smoke coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TestDialecticDepth::test_first_turn_runs_dialectic_synchronously: covered by TestSessionStartDialecticPrewarm::test_turn1_falls_back_to_sync_when_prewarm_missing (more realistic — exercises the empty-prewarm → sync-fallback path) - TestDialecticDepth::test_first_turn_dialectic_does_not_double_fire: covered by TestDialecticLifecycleSmoke (turn 1 flow) and TestDialecticCadenceAdvancesOnSuccess::test_empty_dialectic_result_does_not_advance_cadence Both predate the prewarm refactor and test paths that are now fallback behaviors already covered elsewhere. --- tests/honcho_plugin/test_session.py | 41 ----------------------------- 1 file changed, 41 deletions(-) diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index 37f54b541038..7b5ac7e3d0e4 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -1050,47 +1050,6 @@ def test_run_dialectic_depth_two_passes(self): assert provider._manager.dialectic_query.call_count == 2 assert "Synthesis" in result - def test_first_turn_runs_dialectic_synchronously(self): - """First turn should fire the dialectic synchronously (cold start).""" - from unittest.mock import MagicMock, patch - provider = self._make_provider(cfg_extra={"dialectic_depth": 1}) - provider._manager = MagicMock() - provider._manager.dialectic_query.return_value = "cold start synthesis" - provider._manager.get_prefetch_context.return_value = None - provider._manager.pop_context_result.return_value = None - provider._session_key = "test" - provider._base_context_cache = "" # cold start - provider._last_dialectic_turn = -999 # never fired - - result = provider.prefetch("hello world") - assert "cold start synthesis" in result - assert provider._manager.dialectic_query.call_count == 1 - # After first-turn sync, _last_dialectic_turn should be updated - assert provider._last_dialectic_turn != -999 - - def test_first_turn_dialectic_does_not_double_fire(self): - """After first-turn sync dialectic, queue_prefetch should skip (cadence).""" - from unittest.mock import MagicMock - provider = self._make_provider(cfg_extra={"dialectic_depth": 1}) - provider._manager = MagicMock() - provider._manager.dialectic_query.return_value = "cold start synthesis" - provider._manager.get_prefetch_context.return_value = None - provider._manager.pop_context_result.return_value = None - provider._session_key = "test" - provider._base_context_cache = "" - provider._last_dialectic_turn = -999 - provider._turn_count = 0 - - # First turn fires sync dialectic - provider.prefetch("hello") - assert provider._manager.dialectic_query.call_count == 1 - - # Now queue_prefetch on same turn should skip — _last_dialectic_turn - # was just set to _turn_count by the sync path, so (0 - 0 = 0) < cadence. - provider._manager.dialectic_query.reset_mock() - provider.queue_prefetch("hello") - assert provider._manager.dialectic_query.call_count == 0 - def test_run_dialectic_depth_bails_early_on_strong_signal(self): """Depth 2 skips pass 1 when pass 0 returns strong signal.""" from unittest.mock import MagicMock From f07ed550d70f7cdf9121f9a279ec0c63179b1c32 Mon Sep 17 00:00:00 2001 From: LeonSGP43 <154585401+LeonSGP43@users.noreply.github.com> Date: Fri, 17 Apr 2026 13:49:31 +0800 Subject: [PATCH 6/7] fix(honcho): scope gateway sessions by runtime user id --- plugins/memory/honcho/__init__.py | 9 +--- plugins/memory/honcho/session.py | 9 +++- tests/agent/test_memory_user_id.py | 65 +++++++++++++++++++++++++---- tests/honcho_plugin/test_session.py | 21 +++++----- 4 files changed, 75 insertions(+), 29 deletions(-) diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index 68fa868855c0..d104deb5d52d 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -293,14 +293,6 @@ def initialize(self, session_id: str, **kwargs) -> None: logger.debug("Honcho not configured — plugin inactive") return - # Override peer_name with gateway user_id for per-user memory scoping. - # Only when no explicit peerName was configured — an explicit peerName - # means the user chose their identity; a raw user_id (e.g. Telegram - # chat ID) should not silently replace it. - _gw_user_id = kwargs.get("user_id") - if _gw_user_id and not cfg.peer_name: - cfg.peer_name = _gw_user_id - self._config = cfg # ----- B1: recall_mode from config ----- @@ -359,6 +351,7 @@ def _do_session_init(self, cfg, session_id: str, **kwargs) -> None: honcho=client, config=cfg, context_tokens=cfg.context_tokens, + runtime_user_peer_name=kwargs.get("user_id") or None, ) # ----- B3: resolve_session_name ----- diff --git a/plugins/memory/honcho/session.py b/plugins/memory/honcho/session.py index 7344b517e407..79625b5cd580 100644 --- a/plugins/memory/honcho/session.py +++ b/plugins/memory/honcho/session.py @@ -78,6 +78,7 @@ def __init__( honcho: Honcho | None = None, context_tokens: int | None = None, config: Any | None = None, + runtime_user_peer_name: str | None = None, ): """ Initialize the session manager. @@ -87,10 +88,12 @@ def __init__( context_tokens: Max tokens for context() calls (None = Honcho default). config: HonchoClientConfig from global config (provides peer_name, ai_peer, write_frequency, observation, etc.). + runtime_user_peer_name: Gateway user identity for per-user memory scoping. """ self._honcho = honcho self._context_tokens = context_tokens self._config = config + self._runtime_user_peer_name = runtime_user_peer_name self._cache: dict[str, HonchoSession] = {} self._peers_cache: dict[str, Any] = {} self._sessions_cache: dict[str, Any] = {} @@ -274,8 +277,10 @@ def get_or_create(self, key: str) -> HonchoSession: logger.debug("Local session cache hit: %s", key) return self._cache[key] - # Use peer names from global config when available - if self._config and self._config.peer_name: + # Gateway sessions should use the runtime user identity when available. + if self._runtime_user_peer_name: + user_peer_id = self._sanitize_id(self._runtime_user_peer_name) + elif self._config and self._config.peer_name: user_peer_id = self._sanitize_id(self._config.peer_name) else: # Fallback: derive from session key diff --git a/tests/agent/test_memory_user_id.py b/tests/agent/test_memory_user_id.py index c1b82208d0ee..d33753bd2e1c 100644 --- a/tests/agent/test_memory_user_id.py +++ b/tests/agent/test_memory_user_id.py @@ -208,34 +208,81 @@ def test_different_users_get_different_ids(self): class TestHonchoUserIdScoping: - """Verify Honcho plugin uses gateway user_id for peer_name when provided.""" + """Verify Honcho plugin keeps runtime user scoping separate from config peer_name.""" - def test_gateway_user_id_overrides_peer_name(self): - """When user_id is in kwargs and no explicit peer_name, user_id should be used.""" + def test_gateway_user_id_is_passed_as_runtime_peer(self): + """Gateway user_id should scope Honcho sessions without mutating config peer_name.""" from plugins.memory.honcho import HonchoMemoryProvider provider = HonchoMemoryProvider() - # Create a mock config with NO explicit peer_name mock_cfg = MagicMock() mock_cfg.enabled = True mock_cfg.api_key = "test-key" mock_cfg.base_url = None - mock_cfg.peer_name = "" # No explicit peer_name — user_id should fill it - mock_cfg.recall_mode = "tools" # Use tools mode to defer session init + mock_cfg.peer_name = "static-user" + mock_cfg.recall_mode = "context" + mock_cfg.context_tokens = None + mock_cfg.raw = {} + mock_cfg.dialectic_depth = 1 + mock_cfg.dialectic_depth_levels = None + mock_cfg.init_on_session_start = False + mock_cfg.ai_peer = "hermes" + mock_cfg.resolve_session_name.return_value = "test-sess" + mock_cfg.session_strategy = "shared" with patch( "plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=mock_cfg, - ): + ), patch( + "plugins.memory.honcho.client.get_honcho_client", + return_value=MagicMock(), + ), patch( + "plugins.memory.honcho.session.HonchoSessionManager", + ) as mock_manager_cls: + mock_manager = MagicMock() + mock_manager.get_or_create.return_value = MagicMock(messages=[]) + mock_manager_cls.return_value = mock_manager provider.initialize( session_id="test-sess", user_id="discord_user_789", platform="discord", ) - # The config's peer_name should have been overridden with the user_id - assert mock_cfg.peer_name == "discord_user_789" + assert mock_cfg.peer_name == "static-user" + assert mock_manager_cls.call_args.kwargs["runtime_user_peer_name"] == "discord_user_789" + + def test_session_manager_prefers_runtime_user_id_over_config_peer_name(self): + """Session manager should isolate gateway users even when config peer_name is static.""" + from plugins.memory.honcho.session import HonchoSessionManager + + mock_cfg = MagicMock() + mock_cfg.peer_name = "static-user" + mock_cfg.ai_peer = "hermes" + mock_cfg.write_frequency = "sync" + mock_cfg.dialectic_reasoning_level = "low" + mock_cfg.dialectic_dynamic = True + mock_cfg.dialectic_max_chars = 600 + mock_cfg.observation_mode = "directional" + mock_cfg.user_observe_me = True + mock_cfg.user_observe_others = True + mock_cfg.ai_observe_me = True + mock_cfg.ai_observe_others = True + + manager = HonchoSessionManager( + honcho=MagicMock(), + config=mock_cfg, + runtime_user_peer_name="discord_user_789", + ) + + with patch.object(manager, "_get_or_create_peer", return_value=MagicMock()), patch.object( + manager, + "_get_or_create_honcho_session", + return_value=(MagicMock(), []), + ): + session = manager.get_or_create("discord:channel-1") + + assert session.user_peer_id == "discord_user_789" def test_no_user_id_preserves_config_peer_name(self): """Without user_id, the config peer_name should be preserved.""" diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index 7b5ac7e3d0e4..f2a660292923 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -568,15 +568,15 @@ def _make_provider_with_config(self, recall_mode="tools", init_on_session_start= with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ - patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager) as mock_manager_cls, \ patch("hermes_constants.get_hermes_home", return_value=MagicMock()): provider.initialize(session_id="test-session-001", **init_kwargs) - return provider, cfg + return provider, cfg, mock_manager_cls def test_tools_lazy_default(self): """tools + initOnSessionStart=false → session NOT initialized after initialize().""" - provider, _ = self._make_provider_with_config( + provider, _, _ = self._make_provider_with_config( recall_mode="tools", init_on_session_start=False, ) assert provider._session_initialized is False @@ -585,7 +585,7 @@ def test_tools_lazy_default(self): def test_tools_eager_init(self): """tools + initOnSessionStart=true → session IS initialized after initialize().""" - provider, _ = self._make_provider_with_config( + provider, _, _ = self._make_provider_with_config( recall_mode="tools", init_on_session_start=True, ) assert provider._session_initialized is True @@ -593,33 +593,34 @@ def test_tools_eager_init(self): def test_tools_eager_prefetch_still_empty(self): """tools mode with eager init still returns empty from prefetch() (no auto-injection).""" - provider, _ = self._make_provider_with_config( + provider, _, _ = self._make_provider_with_config( recall_mode="tools", init_on_session_start=True, ) assert provider.prefetch("test query") == "" def test_tools_lazy_prefetch_empty(self): """tools mode with lazy init also returns empty from prefetch().""" - provider, _ = self._make_provider_with_config( + provider, _, _ = self._make_provider_with_config( recall_mode="tools", init_on_session_start=False, ) assert provider.prefetch("test query") == "" def test_explicit_peer_name_not_overridden_by_user_id(self): """Explicit peerName in config must not be replaced by gateway user_id.""" - _, cfg = self._make_provider_with_config( + _, cfg, _ = self._make_provider_with_config( recall_mode="tools", init_on_session_start=True, peer_name="Kathie", user_id="8439114563", ) assert cfg.peer_name == "Kathie" def test_user_id_used_when_no_peer_name(self): - """Gateway user_id is used as peer_name when no explicit peerName configured.""" - _, cfg = self._make_provider_with_config( + """Gateway user_id is passed separately from config peer_name.""" + _, cfg, mock_manager_cls = self._make_provider_with_config( recall_mode="tools", init_on_session_start=True, peer_name=None, user_id="8439114563", ) - assert cfg.peer_name == "8439114563" + assert cfg.peer_name is None + assert mock_manager_cls.call_args.kwargs["runtime_user_peer_name"] == "8439114563" class TestPerSessionMigrateGuard: From 8b477efc07b276fd953e35d428d07a9fbb4c5276 Mon Sep 17 00:00:00 2001 From: Erosika Date: Sat, 18 Apr 2026 13:49:50 -0400 Subject: [PATCH 7/7] feat(honcho): wizard cadence default 2, surface reasoning level, backwards-compat fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup wizard now always writes dialecticCadence=2 on new configs and surfaces the reasoning level as an explicit step with all five options (minimal / low / medium / high / max), always writing dialecticReasoningLevel. Code keeps a backwards-compat fallback of 1 when dialecticCadence is unset so existing honcho.json configs that predate the setting keep firing every turn on upgrade. New setups via the wizard get 2 explicitly; docs show 2 as the default. Also scrubs editorial lines from code and docs ("max is reserved for explicit tool-path selection", "Unset → every turn; wizard pre-fills 2", and similar process-exposing phrasing) and adds an inline link to app.honcho.dev where the server-side observation sync is mentioned in honcho.md. Recommended cadence range updated to 1-5 across docs and wizard copy. --- .../autonomous-ai-agents/honcho/SKILL.md | 4 ++-- plugins/memory/honcho/__init__.py | 10 +++++---- plugins/memory/honcho/cli.py | 22 ++++++++++++++++++- plugins/memory/honcho/client.py | 3 +-- tests/honcho_plugin/test_session.py | 9 ++++---- website/docs/user-guide/features/honcho.md | 8 +++---- .../user-guide/features/memory-providers.md | 2 +- 7 files changed, 40 insertions(+), 18 deletions(-) diff --git a/optional-skills/autonomous-ai-agents/honcho/SKILL.md b/optional-skills/autonomous-ai-agents/honcho/SKILL.md index e79875aa0734..1c099ca605f1 100644 --- a/optional-skills/autonomous-ai-agents/honcho/SKILL.md +++ b/optional-skills/autonomous-ai-agents/honcho/SKILL.md @@ -145,7 +145,7 @@ Controls **how often** dialectic and context calls happen. | Key | Default | Description | |-----|---------|-------------| | `contextCadence` | `1` | Min turns between context API calls | -| `dialecticCadence` | `1` (wizard: `2`) | Min turns between dialectic API calls. Unset → every turn; wizard pre-fills `2` | +| `dialecticCadence` | `2` | Min turns between dialectic API calls. Recommended 1–5 | | `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` for base context injection | Higher cadence values fire the dialectic LLM less often. `dialecticCadence: 2` means the engine fires every other turn. Setting it to `1` fires every turn. @@ -370,7 +370,7 @@ Config file: `$HERMES_HOME/honcho.json` (profile-local) or `~/.honcho/config.jso | `contextTokens` | uncapped | Max tokens for the combined base context injection (summary + representation + card). Opt-in cap — omit to leave uncapped, set to an integer to bound injection size. | | `injectionFrequency` | `every-turn` | `every-turn` or `first-turn` | | `contextCadence` | `1` | Min turns between context API calls | -| `dialecticCadence` | `1` (wizard: `2`) | Min turns between dialectic LLM calls | +| `dialecticCadence` | `2` | Min turns between dialectic LLM calls (recommended 1–5) | The `contextTokens` budget is enforced at injection time. If the session summary + representation + card exceed the budget, Honcho trims the summary first, then the representation, preserving the card. This prevents context blowup in long sessions. diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index d104deb5d52d..6ca32c1dcbb5 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -207,7 +207,7 @@ def __init__(self): self._turn_count = 0 self._injection_frequency = "every-turn" # or "first-turn" self._context_cadence = 1 # minimum turns between context API calls - self._dialectic_cadence = 1 # minimum turns between dialectic API calls + self._dialectic_cadence = 1 # backwards-compat fallback; wizard writes 2 on new configs self._dialectic_depth = 1 # how many .chat() calls per dialectic cycle (1-3) self._dialectic_depth_levels: list[str] | None = None # per-pass reasoning levels self._reasoning_heuristic: bool = True # scale base level by query length @@ -304,6 +304,10 @@ def initialize(self, session_id: str, **kwargs) -> None: raw = cfg.raw or {} self._injection_frequency = raw.get("injectionFrequency", "every-turn") self._context_cadence = int(raw.get("contextCadence", 1)) + # Backwards-compat: unset dialecticCadence falls back to 1 + # (every turn) so existing honcho.json configs without the key + # behave as they did before. New setups via `hermes honcho setup` + # get dialecticCadence=2 written explicitly by the wizard. self._dialectic_cadence = int(raw.get("dialecticCadence", 1)) self._dialectic_depth = max(1, min(cfg.dialectic_depth, 3)) self._dialectic_depth_levels = cfg.dialectic_depth_levels @@ -844,9 +848,7 @@ def liveness_snapshot(self) -> dict: def _apply_reasoning_heuristic(self, base: str, query: str) -> str: """Scale `base` up by query length, clamped at reasoning_level_cap. - Char-count heuristic: +1 at >=120 chars, +2 at >=400. Ceiling is - reasoning_level_cap (default 'high' — 'max' is reserved for - explicit tool-path selection). + Char-count heuristic: +1 at >=120 chars, +2 at >=400. """ if not self._reasoning_heuristic or not query: return base diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index eb21c48eaa8f..5c829a4c989a 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -463,7 +463,8 @@ def cmd_setup(args) -> None: current_dialectic = str(hermes_host.get("dialecticCadence") or cfg.get("dialecticCadence") or "2") print("\n Dialectic cadence:") print(" How often Honcho rebuilds its user model (LLM call on Honcho backend).") - print(" 1 = every turn, 2 = every other turn (wizard default), 3+ = sparse.") + print(" 1 = every turn, 2 = every other turn, 3+ = sparser.") + print(" Recommended: 1-5.") new_dialectic = _prompt("Dialectic cadence", default=current_dialectic) try: val = int(new_dialectic) @@ -472,6 +473,25 @@ def cmd_setup(args) -> None: except (ValueError, TypeError): hermes_host["dialecticCadence"] = 2 + # --- 7c. Dialectic reasoning level --- + current_reasoning = ( + hermes_host.get("dialecticReasoningLevel") + or cfg.get("dialecticReasoningLevel") + or "low" + ) + print("\n Dialectic reasoning level:") + print(" Depth Honcho uses when synthesizing user context on auto-injected calls.") + print(" minimal -- quick factual lookups") + print(" low -- straightforward questions (default)") + print(" medium -- multi-aspect synthesis") + print(" high -- complex behavioral patterns") + print(" max -- thorough audit-level analysis") + new_reasoning = _prompt("Reasoning level", default=current_reasoning) + if new_reasoning in ("minimal", "low", "medium", "high", "max"): + hermes_host["dialecticReasoningLevel"] = new_reasoning + else: + hermes_host["dialecticReasoningLevel"] = "low" + # --- 8. Session strategy --- current_strat = hermes_host.get("sessionStrategy") or cfg.get("sessionStrategy", "per-session") print("\n Session strategy:") diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 346c2b76e688..fef2e2d58f1e 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -254,8 +254,7 @@ class HonchoClientConfig: # When true, the auto-injected dialectic scales reasoning level up on # longer queries. See HonchoMemoryProvider for thresholds. reasoning_heuristic: bool = True - # Ceiling for the heuristic-selected reasoning level. "max" is reserved - # for explicit tool-path selection. + # Ceiling for the heuristic-selected reasoning level. reasoning_level_cap: str = "high" # Honcho API limits — configurable for self-hosted instances # Max chars per message sent via add_messages() (Honcho cloud: 25000) diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index f2a660292923..254261183120 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -865,8 +865,10 @@ def _make_provider(cfg_extra=None): _settle_prewarm(provider) return provider - def test_default_is_1(self): - """Default dialectic_cadence is 1 — fires every turn unless overridden.""" + def test_unset_falls_back_to_1(self): + """Unset dialecticCadence falls back to 1 (every turn) for backwards + compatibility with existing configs that predate the setting. The + setup wizard writes 2 explicitly on new configs.""" provider = self._make_provider() assert provider._dialectic_cadence == 1 @@ -1569,8 +1571,7 @@ def test_full_multi_turn_session(self): class TestReasoningHeuristic: """Char-count heuristic that scales the auto-injected reasoning level by - query length, clamped at reasoning_level_cap. 'max' is reserved for - explicit tool-path selection.""" + query length, clamped at reasoning_level_cap.""" @staticmethod def _make_provider(cfg_extra=None): diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index bf4b5c6bc37c..60e82b4b08fb 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -77,7 +77,7 @@ Cost and depth are controlled by three independent knobs: | Knob | Controls | Default | |------|----------|---------| | `contextCadence` | Turns between `context()` API calls (base layer refresh) | `1` | -| `dialecticCadence` | Turns between `peer.chat()` LLM calls (dialectic layer refresh) | `1` (code default) / `2` (setup wizard default) | +| `dialecticCadence` | Turns between `peer.chat()` LLM calls (dialectic layer refresh) | `2` (recommended 1–5) | | `dialecticDepth` | Number of `.chat()` passes per dialectic invocation (1–3) | `1` | These are orthogonal — you can have frequent context refreshes with infrequent dialectic, or deep multi-pass dialectic at low frequency. Example: `contextCadence: 1, dialecticCadence: 5, dialecticDepth: 2` refreshes base context every turn, runs dialectic every 5 turns, and each dialectic run makes 2 passes. @@ -100,7 +100,7 @@ On session init, Honcho fires a dialectic call in the background at the full con ### Query-Adaptive Reasoning Level -The auto-injected dialectic scales `dialecticReasoningLevel` by query length: +1 level at ≥120 chars, +2 at ≥400, clamped at `reasoningLevelCap` (default `"high"`). Disable with `reasoningHeuristic: false` to pin every auto call to `dialecticReasoningLevel`. `"max"` is reserved for explicit tool-path selection via `honcho_reasoning`. +The auto-injected dialectic scales `dialecticReasoningLevel` by query length: +1 level at ≥120 chars, +2 at ≥400, clamped at `reasoningLevelCap` (default `"high"`). Disable with `reasoningHeuristic: false` to pin every auto call to `dialecticReasoningLevel`. Available levels: `minimal`, `low`, `medium`, `high`, `max`. ## Configuration Options @@ -112,7 +112,7 @@ Honcho is configured in `~/.honcho/config.json` (global) or `$HERMES_HOME/honcho |-----|---------|-------------| | `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Set to an integer (e.g. 1200) to cap. Truncates at word boundaries | | `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) | -| `dialecticCadence` | `1` (wizard sets `2`) | Minimum turns between `peer.chat()` LLM calls (dialectic layer). Code default fires every turn when the key is unset; the setup wizard pre-fills `2`. In `tools` mode, irrelevant — model calls explicitly | +| `dialecticCadence` | `2` | Minimum turns between `peer.chat()` LLM calls (dialectic layer). Recommended 1–5. In `tools` mode, irrelevant — model calls explicitly | | `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped to 1–3 | | `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `["minimal", "low", "medium"]`. Overrides proportional defaults | | `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` | @@ -183,7 +183,7 @@ Common patterns: | AI shouldn't re-model the user from its own replies | `"ai": {"observeMe": true, "observeOthers": false}` | | Strong persona the AI peer shouldn't update from self-observation | `"ai": {"observeMe": false, "observeOthers": true}` | -Server-side toggles set via the Honcho dashboard win over local defaults — Hermes syncs them back at session init. +Server-side toggles set via the [Honcho dashboard](https://app.honcho.dev) win over local defaults — Hermes syncs them back at session init. ## Tools diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index b2469a13ee37..d11c36657a3e 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -82,7 +82,7 @@ hermes memory setup # select "honcho" | `workspace` | host key | Shared workspace ID | | `contextTokens` | `null` (uncapped) | Token budget for auto-injected context per turn. Truncates at word boundaries | | `contextCadence` | `1` | Minimum turns between `context()` API calls (base layer refresh) | -| `dialecticCadence` | `1` (wizard sets `2`) | Minimum turns between `peer.chat()` LLM calls. Unset → every turn; wizard pre-fills `2`. Only applies to `hybrid`/`context` modes | +| `dialecticCadence` | `2` | Minimum turns between `peer.chat()` LLM calls. Recommended 1–5. Only applies to `hybrid`/`context` modes | | `dialecticDepth` | `1` | Number of `.chat()` passes per dialectic invocation. Clamped 1–3. Pass 0: cold/warm prompt, pass 1: self-audit, pass 2: reconciliation | | `dialecticDepthLevels` | `null` | Optional array of reasoning levels per pass, e.g. `["minimal", "low", "medium"]`. Overrides proportional defaults | | `dialecticReasoningLevel` | `'low'` | Base reasoning level: `minimal`, `low`, `medium`, `high`, `max` |