Honcho memory gateway scoping and lifecycle fixes

NousResearch/hermes-agent · cluster 11199

Data as of

Nothing open on GitHub. Fix merged in #12419. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #12419. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #12419. That is separate from the fix event.
Still open
Nothing open on GitHub.

These issues and pull requests concern bugs and fixes related to how Honcho memory scopes sessions by gateway user ID, particularly when peer_name is configured, as well as dialectic lifecycle behavior including defaults, retry, and prewarm handling.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #12419 fix(honcho): dialectic lifecycle, gateway scoping, provider opt-in

    merged to main

    files: optional-skills/autonomous-ai-agents/honcho/SKILL.md, plugins/memory/honcho/__init__.py, plugins/memory/honcho/cli.py, plugins/memory/honcho/client.py, plugins/memory/honcho/session.py, run_agent.py, scripts/release.py, tests/agent/test_memory_provider.py, tests/agent/test_memory_user_id.py, tests/honcho_plugin/test_async_memory.py, tests/honcho_plugin/test_cli.py, tests/honcho_plugin/test_session.py, tests/run_agent/test_memory_provider_init.py, website/docs/user-guide/features/honcho.md, website/docs/user-guide/features/memory-providers.md

    Show the diff
    • 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` | `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 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` | `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.
       
    • 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
      @@ -206,13 +207,19 @@ 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  # 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_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
       
      +        # 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
      @@ -286,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 -----
      @@ -305,12 +304,16 @@ 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))
      +                # 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
      -                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)
       
      @@ -352,6 +355,7 @@ def _do_session_init(self, cfg, session_id: str, **kwargs) -> None:
                   honcho=client,
                   config=cfg,
                   context_tokens=[redacted:secret-assignment]
      +            runtime_user_peer_name=kwargs.get("user_id") or None,
               )
       
               # ----- B3: resolve_session_name -----
      @@ -391,14 +395,45 @@ 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() (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)
      -                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)
      +                    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"
      +            )
      +            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).
      @@ -487,7 +522,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."
                   )
      @@ -497,7 +533,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."
                   )
       
      @@ -526,6 +563,10 @@ def prefetch(self, query: str, *, session_id: str = "") -> str:
               if self._injection_frequency == "first-turn" and self._turn_count > 1:
                   return ""
       
      +        # Trivial prompts ("ok", "yes", slash commands) carry no semantic signal.
      +        if self._is_trivial_prompt(query):
      +            return ""
      +
               parts = []
       
               # ----- Layer 1: Base context (representation + card) -----
      @@ -560,43 +601,72 @@ 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():
      +                    self._dialectic_empty_streak += 1
      +                    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
      +                        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"
      +            )
      +            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)
               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)
      @@ -641,6 +711,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,24 +724,46 @@ 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
      +        # 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
       
      -        self._last_dialectic_turn = self._turn_count
      +        # 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
      +        # (transient API error, sparse representation) retry next turn.
      +        _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)
      +                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"
               )
      @@ -692,11 +788,91 @@ def _run():
       
           _LEVEL_ORDER = ("minimal", "low", "medium", "high", "max")
       
      -    def _resolve_pass_level(self, pass_idx: int) -> str:
      +    # Char-count thresholds for the query-length reasoning heuristic.
      +    _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.
      +
      +        Char-count heuristic: +1 at >=120 chars, +2 at >=400.
      +        """
      +        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 +880,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 +967,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 +984,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
    • plugins/memory/honcho/cli.py
      @@ -460,17 +460,37 @@ 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 "2")
           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, 2 = every other turn, 3+ = sparser.")
      +    print("    Recommended: 1-5.")
           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"] = 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")
      @@ -636,8 +656,11 @@ 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 ''}")
      +    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}")
       
    • plugins/memory/honcho/client.py
      @@ -251,6 +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
      +    # 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.
      +    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 +451,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")
    • 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 = [redacted:secret-assignment]
               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] = {}
      @@ -100,9 +103,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"
      @@ -272,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
      @@ -499,8 +506,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 +562,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.
    • run_agent.py
      @@ -1306,31 +1306,6 @@ def __init__(
                   try:
                       _mem_provider_name = mem_config.get("provider", "") if mem_config else ""
       
      -                # Auto-migrate: if Honcho was actively configured (enabled +
      -                # credentials) but memory.provider is not set, activate the
      -                # honcho plugin automatically.  Just having the config file
      -                # is not enough — the user may have disabled Honcho or the
      -                # file may be from a different tool.
      -                if not _mem_provider_name:
      -                    try:
      -                        from plugins.memory.honcho.client import HonchoClientConfig as _HCC
      -                        _hcfg = _HCC.from_global_config()
      -                        if _hcfg.enabled and (_hcfg.api_key or _hcfg.base_url):
      -                            _mem_provider_name = "honcho"
      -                            # Persist so this only auto-migrates once
      -                            try:
      -                                from hermes_cli.config import load_config as _lc, save_config as _sc
      -                                _cfg = _lc()
      -                                _cfg.setdefault("memory", {})["provider"] = "honcho"
      -                                _sc(_cfg)
      -                            except Exception:
      -                                pass
      -                            if not self.quiet_mode:
      -                                print("  ✓ Auto-migrated Honcho to memory provider plugin.")
      -                                print("    Your config and data are preserved.\n")
      -                    except Exception:
      -                        pass
      -
                       if _mem_provider_name:
                           from agent.memory_manager import MemoryManager as _MemoryManager
                           from plugins.memory import load_memory_provider as _load_mem
    • scripts/release.py
      @@ -215,6 +215,7 @@
           "[redacted:email]": "ziliangpeng",
           "[redacted:email]": "centripetal-star",
           "[redacted:email]": "LeonSGP43",
      +    "[redacted:email]": "LeonSGP43",
           "[redacted:email]": "Lubrsy706",
           "[redacted:email]": "spniyant",
           "[redacted:email]": "olafthiele",
    • 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()
       
    • 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 = "[redacted:secret-assignment]"
               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."""
    • 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") == ""
    • 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"
    • 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:
      @@ -815,6 +816,27 @@ 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_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()
      +            provider._manager.prefetch_context.reset_mock()
      +        except AttributeError:
      +            pass
      +
      +
       class TestDialecticCadenceDefaults:
           """Regression tests for dialectic_cadence default value."""
       
      @@ -840,12 +862,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_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 == 3
      +        assert provider._dialectic_cadence == 1
       
           def test_config_override(self):
               """dialecticCadence from config overrides the default."""
      @@ -908,6 +933,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):
      @@ -1027,60 +1053,598 @@ 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})
      +    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
      +        provider = self._make_provider(cfg_extra={"dialectic_depth": 2})
               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._manager.dialectic_query.return_value = (
      +            "## Full Assessment\n- Strong structured response\n- With evidence\n" + "x" * 200
      +        )
               provider._session_key = "test"
      -        provider._base_context_cache = ""  # cold start
      -        provider._last_dialectic_turn = -999  # never fired
      +        provider._base_context_cache = "existing context"
       
      -        result = provider.prefetch("hello world")
      -        assert "cold start synthesis" in result
      +        result = provider._run_dialectic_depth("test query")
      +        # Only 1 call because pass 0 had sufficient signal
               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
      +
      +# ---------------------------------------------------------------------------
      +# Trivial-prompt heuristic + dialectic cadence silent-failure guards
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestTrivialPromptHeuristic:
      +    """Trivial prompts ('ok', 'y', slash commands) must short-circuit injection."""
      +
      +    @staticmethod
      +    def _make_provider():
      +        from unittest.mock import patch, MagicMock
      +        from plugins.memory.honcho.client import HonchoClientConfig
      +
      +        cfg = HonchoClientConfig(api_key="[redacted:secret-assignment]", 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 = ""
      -        provider._last_dialectic_turn = -999
      -        provider._turn_count = 0
      +        provider._base_context_cache = "cached base"
      +        provider._last_dialectic_turn = 0
      +        provider._turn_count = 5
       
      -        # First turn fires sync dialectic
      -        provider.prefetch("hello")
      -        assert provider._manager.dialectic_query.call_count == 1
      +        assert provider.prefetch("ok") == ""
      +        assert provider.prefetch("/help") == ""
      +        # Dialectic should not have fired
      +        assert provider._manager.dialectic_query.call_count == 0
       
      -        # Now queue_prefetch on same turn should skip (cadence: 0 - 0 < 3)
      +    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("hello")
      +
      +        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
       
      -    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
      -        provider = self._make_provider(cfg_extra={"dialectic_depth": 2})
      -        provider._manager = MagicMock()
      -        provider._manager.dialectic_query.return_value = (
      -            "## Full Assessment\n- Strong structured response\n- With evidence\n" + "x" * 200
      +
      +class TestDialecticCadenceAdvancesOnSuccess:
      +    """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():
      +        from unittest.mock import patch, MagicMock
      +        from plugins.memory.honcho.client import HonchoClientConfig
      +
      +        cfg = HonchoClientConfig(
      +            api_key="[redacted:secret-assignment]", 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._base_context_cache = "existing context"
      +        provider._manager.dialectic_query.return_value = ""  # silent failure
      +        provider._turn_count = 5
      +        provider._last_dialectic_turn = 0  # would fire (5 - 0 = 5 ≥ 3)
       
      -        result = provider._run_dialectic_depth("test query")
      -        # Only 1 call because pass 0 had sufficient signal
      +        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
      +        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 (fresh, not stale)
      +        hold = _threading.Event()
      +
      +        def _block():
      +            hold.wait(timeout=5.0)
      +
      +        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()
      +        fresh.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="[redacted:secret-assignment]", 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 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="[redacted:secret-assignment]", 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,
      +    heuristic bump, and session-end flush."""
      +
      +    @staticmethod
      +    def _make_provider(cfg_extra=None):
      +        from unittest.mock import patch, MagicMock
      +        from plugins.memory.honcho.client import HonchoClientConfig
      +
      +        defaults = dict(
      +            api_key="[redacted:secret-assignment]", 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:
      +    """Char-count heuristic that scales the auto-injected reasoning level by
      +    query length, clamped at reasoning_level_cap."""
      +
      +    @staticmethod
      +    def _make_provider(cfg_extra=None):
      +        from unittest.mock import patch, MagicMock
      +        from plugins.memory.honcho.client import HonchoClientConfig
      +
      +        defaults = dict(
      +            api_key="[redacted:secret-assignment]", 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"
       
       
       # ---------------------------------------------------------------------------
    • tests/run_agent/test_memory_provider_init.py
      @@ -0,0 +1,39 @@
      +"""Regression tests for memory provider selection during AIAgent init."""
      +
      +from types import SimpleNamespace
      +from unittest.mock import patch
      +
      +
      +def test_blank_memory_provider_does_not_auto_enable_honcho():
      +    """Blank memory.provider should remain opt-out even if Honcho fallback looks configured."""
      +    cfg = {"memory": {"provider": ""}, "agent": {}}
      +    honcho_cfg = SimpleNamespace(enabled=True, api_key="[redacted:secret-assignment]", base_url=None)
      +
      +    with (
      +        patch("hermes_cli.config.load_config", return_value=cfg),
      +        patch("hermes_cli.config.save_config") as save_config,
      +        patch(
      +            "plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
      +            return_value=honcho_cfg,
      +        ) as from_global_config,
      +        patch("plugins.memory.load_memory_provider") as load_memory_provider,
      +        patch("agent.model_metadata.get_model_context_length", return_value=204_800),
      +        patch("run_agent.get_tool_definitions", return_value=[]),
      +        patch("run_agent.check_toolset_requirements", return_value={}),
      +        patch("run_agent.OpenAI"),
      +    ):
      +        from run_agent import AIAgent
      +
      +        agent = AIAgent(
      +            api_key="[redacted:secret-assignment]",
      +            base_url="https://openrouter.ai/api/v1",
      +            quiet_mode=True,
      +            skip_context_files=True,
      +            skip_memory=False,
      +        )
      +
      +    assert agent._memory_manager is None
      +    from_global_config.assert_not_called()
      +    load_memory_provider.assert_not_called()
      +    save_config.assert_not_called()
      +
    • 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) | `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.
      @@ -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`. Available levels: `minimal`, `low`, `medium`, `high`, `max`.
      +
       ## 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` | `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` |
      @@ -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](https://app.honcho.dev) win over local defaults — Hermes syncs them back at session init.
      +
       ## Tools
       
       When Honcho is active as the memory provider, five tools become available:
    • 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` | `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` |
      @@ -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.<profile>` 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.
       
       <details>
       <summary>Full honcho.json example (multi-profile)</summary>
      @@ -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,

2 PRs closed without merging

From the close comments: merged via another change 2

  • #11434 fix(honcho): scope gateway sessions by runtime user id · merged via another change → #12419, #12160
  • #12160 fix(honcho): dialectic lifecycle — defaults, retry, prewarm consumption · merged via another change → #12419, #11434
Closed issues · 2
  • Closed#11199[Bug]: Honcho memory scoping ignores gateway user_id when peer_name is configured
  • Closed#14401Honcho memory plugin ignores gateway user_id when peer_name is configured
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.34: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Merged PRs
#12419
Closed issues
2
Attempts
2 PRs closed without merging
Fix link checked
19 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.