Honcho peer identity resolution and alias mapping fixes

NousResearch/hermes-agent · cluster 14984 · agent

Data as of

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

The short answer

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

These issues and pull requests address bugs and features around how the Honcho memory provider resolves, maps, and stabilizes peer identities across platforms, gateways, and multi-user setups. The work centers on ensuring explicit peerName configuration and alias lookups take precedence over auto-generated or platform-derived peer IDs.

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.

  • #15381 fix(honcho): bug-fix consolidation — 7 upstream PRs, preserved authorship

    merged to main

    files: agent/memory_manager.py, gateway/run.py, hermes_state.py, 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_streaming_context_scrubber.py, tests/gateway/test_vision_memory_leak.py, tests/honcho_plugin/test_cli.py, tests/honcho_plugin/test_client.py, tests/honcho_plugin/test_empty_profile_hint.py, tests/honcho_plugin/test_pin_peer_name.py, tests/honcho_plugin/test_session.py, tests/run_agent/test_run_agent.py, tests/run_agent/test_run_agent_codex_responses.py, tests/test_hermes_state.py

    Show the diff
    • agent/memory_manager.py
      @@ -63,15 +63,124 @@ def sanitize_context(text: str) -> str:
           return text
       
       
      -def build_memory_context_block(raw_context: str) -> str:
      -    """Wrap prefetched memory in a fenced block with system note.
      -
      -    The fence prevents the model from treating recalled context as user
      -    discourse.  Injected at API-call time only — never persisted.
      +class StreamingContextScrubber:
      +    """Stateful scrubber for streaming text that may contain split memory-context spans.
      +
      +    The one-shot ``sanitize_context`` regex cannot survive chunk boundaries:
      +    a ``<memory-context>`` opened in one delta and closed in a later delta
      +    leaks its payload to the UI because the non-greedy block regex needs
      +    both tags in one string.  This scrubber runs a small state machine
      +    across deltas, holding back partial-tag tails and discarding
      +    everything inside a span (including the system-note line).
      +
      +    Usage::
      +
      +        scrubber = StreamingContextScrubber()
      +        for delta in stream:
      +            visible = scrubber.feed(delta)
      +            if visible:
      +                emit(visible)
      +        trailing = scrubber.flush()  # at end of stream
      +        if trailing:
      +            emit(trailing)
      +
      +    The scrubber is re-entrant per agent instance.  Callers building new
      +    top-level responses (new turn) should create a fresh scrubber or call
      +    ``reset()``.
           """
      +
      +    _OPEN_TAG = "<memory-context>"
      +    _CLOSE_TAG = "</memory-context>"
      +
      +    def __init__(self) -> None:
      +        self._in_span: bool = False
      +        self._buf: str = ""
      +
      +    def reset(self) -> None:
      +        self._in_span = False
      +        self._buf = ""
      +
      +    def feed(self, text: str) -> str:
      +        """Return the visible portion of ``text`` after scrubbing.
      +
      +        Any trailing fragment that could be the start of an open/close tag
      +        is held back in the internal buffer and surfaced on the next
      +        ``feed()`` call or discarded/emitted by ``flush()``.
      +        """
      +        if not text:
      +            return ""
      +        buf = self._buf + text
      +        self._buf = ""
      +        out: list[str] = []
      +
      +        while buf:
      +            if self._in_span:
      +                idx = buf.lower().find(self._CLOSE_TAG)
      +                if idx == -1:
      +                    # Hold back a potential partial close tag; drop the rest
      +                    held = self._max_partial_suffix(buf, self._CLOSE_TAG)
      +                    self._buf = buf[-held:] if held else ""
      +                    return "".join(out)
      +                # Found close — skip span content + tag, continue
      +                buf = buf[idx + len(self._CLOSE_TAG):]
      +                self._in_span = False
      +            else:
      +                idx = buf.lower().find(self._OPEN_TAG)
      +                if idx == -1:
      +                    # No open tag — hold back a potential partial open tag
      +                    held = self._max_partial_suffix(buf, self._OPEN_TAG)
      +                    if held:
      +                        out.append(buf[:-held])
      +                        self._buf = buf[-held:]
      +                    else:
      +                        out.append(buf)
      +                    return "".join(out)
      +                # Emit text before the tag, enter span
      +                if idx > 0:
      +                    out.append(buf[:idx])
      +                buf = buf[idx + len(self._OPEN_TAG):]
      +                self._in_span = True
      +
      +        return "".join(out)
      +
      +    def flush(self) -> str:
      +        """Emit any held-back buffer at end-of-stream.
      +
      +        If we're still inside an unterminated span the remaining content is
      +        discarded (safer: leaking partial memory context is worse than a
      +        truncated answer).  Otherwise the held-back partial-tag tail is
      +        emitted verbatim (it turned out not to be a real tag).
      +        """
      +        if self._in_span:
      +            self._buf = ""
      +            self._in_span = False
      +            return ""
      +        tail = self._buf
      +        self._buf = ""
      +        return tail
      +
      +    @staticmethod
      +    def _max_partial_suffix(buf: str, tag: str) -> int:
      +        """Return the length of the longest buf-suffix that is a tag-prefix.
      +
      +        Case-insensitive.  Returns 0 if no suffix could start the tag.
      +        """
      +        tag_lower = tag.lower()
      +        buf_lower = buf.lower()
      +        max_check = min(len(buf_lower), len(tag_lower) - 1)
      +        for i in range(max_check, 0, -1):
      +            if tag_lower.startswith(buf_lower[-i:]):
      +                return i
      +        return 0
      +
      +
      +def build_memory_context_block(raw_context: str) -> str:
      +    """Wrap prefetched memory in a fenced block with system note."""
           if not raw_context or not raw_context.strip():
               return ""
           clean = sanitize_context(raw_context)
      +    if clean != raw_context:
      +        logger.warning("memory provider returned pre-wrapped context; stripped")
           return (
               "<memory-context>\n"
               "[System note: The following is recalled memory context, "
    • gateway/run.py
      @@ -8483,6 +8483,7 @@ async def _enrich_message_with_vision(
                   The enriched message string with vision descriptions prepended.
               """
               from tools.vision_tools import vision_analyze_tool
      +        from agent.memory_manager import sanitize_context
       
               analysis_prompt = (
                   "Describe everything visible in this image in thorough detail. "
      @@ -8501,6 +8502,7 @@ async def _enrich_message_with_vision(
                       result = json.loads(result_json)
                       if result.get("success"):
                           description = result.get("analysis", "")
      +                    description = sanitize_context(description)
                           enriched_parts.append(
                               f"[The user sent an image~ Here's what I can see:\n{description}]\n"
                               f"[If you need a closer look, use vision_analyze with "
    • hermes_state.py
      @@ -22,6 +22,8 @@
       import threading
       import time
       from pathlib import Path
      +
      +from agent.memory_manager import sanitize_context
       from hermes_constants import get_hermes_home
       from typing import Any, Callable, Dict, List, Optional, TypeVar
       
      @@ -1155,7 +1157,10 @@ def get_messages_as_conversation(
       
               messages = []
               for row in rows:
      -            msg = {"role": row["role"], "content": row["content"]}
      +            content = row["content"]
      +            if row["role"] in {"user", "assistant"} and isinstance(content, str):
      +                content = sanitize_context(content).strip()
      +            msg = {"role": row["role"], "content": content}
                   if row["tool_call_id"]:
                       msg["tool_call_id"] = row["tool_call_id"]
                   if row["tool_name"]:
    • plugins/memory/honcho/__init__.py
      @@ -22,6 +22,7 @@
       import time
       from typing import Any, Dict, List, Optional
       
      +from agent.memory_manager import sanitize_context
       from agent.memory_provider import MemoryProvider
       from tools.registry import tool_error
       
      @@ -37,7 +38,10 @@
           "description": (
               "Retrieve or update a peer card from Honcho — a curated list of key facts "
               "about that peer (name, role, preferences, communication style, patterns). "
      -        "Pass `card` to update; omit `card` to read."
      +        "Pass `card` to update; omit `card` to read.  If the card is empty, the "
      +        "result includes a `hint` field explaining why (observation disabled, "
      +        "fresh peer, dialectic layer still warming up, etc.) — this is NOT an "
      +        "error.  Peer cards accumulate over time from observed conversation."
           ),
           "parameters": {
               "type": "object",
      @@ -1056,6 +1060,63 @@ def _chunk_message(content: str, limit: int) -> list[str]:
       
               return chunks
       
      +    def _empty_profile_hint(self, peer: str) -> Dict[str, Any]:
      +        """Build a diagnostic hint when honcho_profile returns an empty card.
      +
      +        A literal "No profile facts available yet." tells the model nothing
      +        about WHY.  The model then often surfaces it to the user as a cryptic
      +        error.  This hint enumerates the likely causes so the model can
      +        explain the situation (or retry with a different peer).
      +
      +        Ordered by likelihood for a typical deployment:
      +          1. Observation is disabled for this peer
      +          2. Card hasn't accumulated yet (fresh peer, not enough dialectic
      +             cycles — dialectic cadence runs every N turns)
      +          3. Self-hosted Honcho backend doesn't support peer cards
      +             (honcho-ai server < 3.x)
      +        """
      +        cfg = self._config
      +        reasons: List[str] = []
      +
      +        if cfg is not None:
      +            if peer == "user":
      +                observe_me = bool(getattr(cfg, "user_observe_me", True))
      +                observe_others = bool(getattr(cfg, "user_observe_others", True))
      +            else:
      +                observe_me = bool(getattr(cfg, "ai_observe_me", True))
      +                observe_others = bool(getattr(cfg, "ai_observe_others", True))
      +            if not (observe_me or observe_others):
      +                reasons.append(
      +                    f"observation is disabled for peer '{peer}' "
      +                    f"(user_observe_me/ai_observe_me in config)"
      +                )
      +
      +        cadence = getattr(self, "_dialectic_cadence", 1)
      +        turn = getattr(self, "_turn_count", 0)
      +        if turn < max(2, cadence):
      +            reasons.append(
      +                f"this session has only {turn} turn(s); peer cards accumulate "
      +                f"as the dialectic layer reasons over conversation history "
      +                f"(cadence every {cadence} turn(s))"
      +            )
      +
      +        if not reasons:
      +            reasons.append(
      +                "peer card has no facts yet — Honcho's dialectic layer builds "
      +                "this over time from observed turns; self-hosted Honcho < 3.x "
      +                "does not support peer cards at all"
      +            )
      +
      +        return {
      +            "result": "No profile facts available yet.",
      +            "hint": (
      +                "This is not an error.  "
      +                + "; ".join(reasons)
      +                + ".  Try honcho_reasoning for a synthesized answer, or "
      +                "honcho_search to query raw conversation excerpts."
      +            ),
      +        }
      +
           def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
               """Record the conversation turn in Honcho (non-blocking).
       
      @@ -1068,13 +1129,15 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st
                   return
       
               msg_limit = self._config.message_max_chars if self._config else 25000
      +        clean_user_content = sanitize_context(user_content or "").strip()
      +        clean_assistant_content = sanitize_context(assistant_content or "").strip()
       
               def _sync():
                   try:
                       session = self._manager.get_or_create(self._session_key)
      -                for chunk in self._chunk_message(user_content, msg_limit):
      +                for chunk in self._chunk_message(clean_user_content, msg_limit):
                           session.add_message("user", chunk)
      -                for chunk in self._chunk_message(assistant_content, msg_limit):
      +                for chunk in self._chunk_message(clean_assistant_content, msg_limit):
                           session.add_message("assistant", chunk)
                       self._manager._flush_session(session)
                   except Exception as e:
      @@ -1087,8 +1150,20 @@ def _sync():
               )
               self._sync_thread.start()
       
      -    def on_memory_write(self, action: str, target: str, content: str) -> None:
      -        """Mirror built-in user profile writes as Honcho conclusions."""
      +    def on_memory_write(
      +        self,
      +        action: str,
      +        target: str,
      +        content: str,
      +        metadata: Optional[Dict[str, Any]] = None,
      +    ) -> None:
      +        """Mirror built-in user profile writes as Honcho conclusions.
      +
      +        ``metadata`` is accepted for compatibility with the write-origin
      +        work landed in main (commit 6a957a74); it's not yet threaded into
      +        the Honcho conclusion payload.  Left as a follow-up so this PR
      +        stays focused on the 7-PR consolidation and its review follow-ups.
      +        """
               if action != "add" or target != "user" or not content:
                   return
               if self._cron_skipped:
      @@ -1154,7 +1229,7 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
                           return json.dumps({"result": f"Peer card updated ({len(result)} facts).", "card": result})
                       card = self._manager.get_peer_card(self._session_key, peer=peer)
                       if not card:
      -                    return json.dumps({"result": "No profile facts available yet."})
      +                    return json.dumps(self._empty_profile_hint(peer))
                       return json.dumps({"result": card})
       
                   elif tool_name == "honcho_search":
    • plugins/memory/honcho/cli.py
      @@ -273,9 +273,38 @@ def _write_config(cfg: dict, path: Path | None = None) -> None:
       
       
       def _resolve_api_key(cfg: dict) -> str:
      -    """Resolve API key with host -> root -> env fallback."""
      +    """Resolve API key with host -> root -> env fallback.
      +
      +    For self-hosted instances configured with ``baseUrl`` instead of an API
      +    key, returns ``"local"`` so that credential guards throughout the CLI
      +    don't reject a valid configuration.  The ``baseUrl`` is scheme-validated
      +    (http/https only) so that a typo like ``baseUrl: true`` can't silently
      +    pass the guard.  Schemeless strings that look like host:port (legacy
      +    config shapes, e.g. ``localhost:8000``) still pass — the Honcho SDK
      +    will reject them itself with a clearer error than ours.
      +    """
           host_key = ((cfg.get("hosts") or {}).get(_host_key()) or {}).get("apiKey")
      -    return host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
      +    key = host_key or cfg.get("apiKey", "") or os.environ.get("HONCHO_API_KEY", "")
      +    if not key:
      +        base_url = cfg.get("baseUrl") or cfg.get("base_url") or os.environ.get("HONCHO_BASE_URL", "")
      +        base_url = (base_url or "").strip()
      +        if base_url:
      +            from urllib.parse import urlparse
      +            try:
      +                parsed = urlparse(base_url)
      +            except (TypeError, ValueError):
      +                parsed = None
      +            if parsed and parsed.scheme in ("http", "https") and parsed.netloc:
      +                return "local"
      +            # Schemeless but looks like a host (contains '.' or ':' and isn't
      +            # a boolean literal): let it through so legacy configs don't
      +            # regress into "no API key configured" when they previously worked.
      +            lowered = base_url.lower()
      +            if lowered not in ("true", "false", "none", "null") and any(
      +                c in base_url for c in ".:"
      +            ) and not base_url.isdigit():
      +                return "local"
      +    return key
       
       
       def _prompt(label: str, default: str | None = None, secret: bool = False) -> str:
    • plugins/memory/honcho/client.py
      @@ -16,6 +16,7 @@
       import json
       import os
       import logging
      +import hashlib
       from dataclasses import dataclass, field
       from pathlib import Path
       
      @@ -27,7 +28,6 @@
       
       logger = logging.getLogger(__name__)
       
      -GLOBAL_CONFIG_PATH = Path.home() / ".honcho" / "config.json"
       HOST = "hermes"
       
       
      @@ -53,6 +53,11 @@ def resolve_active_host() -> str:
           return HOST
       
       
      +def resolve_global_config_path() -> Path:
      +    """Return the shared Honcho config path for the current HOME."""
      +    return Path.home() / ".honcho" / "config.json"
      +
      +
       def resolve_config_path() -> Path:
           """Return the active Honcho config path.
       
      @@ -72,7 +77,7 @@ def resolve_config_path() -> Path:
           if default_path != local_path and default_path.exists():
               return default_path
       
      -    return GLOBAL_CONFIG_PATH
      +    return resolve_global_config_path()
       
       
       _RECALL_MODE_ALIASES = {"auto": "hybrid"}
      @@ -138,6 +143,15 @@ def _parse_dialectic_depth_levels(host_val, root_val, depth: int) -> list[str] |
           return None
       
       
      +# Default HTTP timeout (seconds) applied when no explicit timeout is
      +# configured via HonchoClientConfig.timeout, honcho.timeout / requestTimeout,
      +# or HONCHO_TIMEOUT. Honcho calls happen on the post-response path of
      +# run_conversation; without a cap the agent can block indefinitely when
      +# the Honcho backend is unreachable, preventing the gateway from
      +# delivering the already-generated response.
      +_DEFAULT_HTTP_TIMEOUT = 30.0
      +
      +
       def _resolve_optional_float(*values: Any) -> float | None:
           """Return the first non-empty value coerced to a positive float."""
           for value in values:
      @@ -226,6 +240,13 @@ class HonchoClientConfig:
           # Identity
           peer_name: str | None = None
           ai_peer: str = "hermes"
      +    # When True, ``peer_name`` wins over any gateway-supplied runtime
      +    # identity (Telegram UID, Discord ID, …) when resolving the user peer.
      +    # This keeps memory unified across platforms for single-user deployments
      +    # where Honcho's one peer-name is an unambiguous identity — otherwise
      +    # each platform would fork memory into its own peer (#14984).  Default
      +    # ``False`` preserves existing multi-user behaviour.
      +    pin_peer_name: bool = False
           # Toggles
           enabled: bool = False
           save_messages: bool = True
      @@ -420,6 +441,11 @@ def from_global_config(
                   timeout=timeout,
                   peer_name=host_block.get("peerName") or raw.get("peerName"),
                   ai_peer=ai_peer,
      +            pin_peer_name=_resolve_bool(
      +                host_block.get("pinPeerName"),
      +                raw.get("pinPeerName"),
      +                default=False,
      +            ),
                   enabled=enabled,
                   save_messages=save_messages,
                   write_frequency=write_frequency,
      @@ -522,6 +548,39 @@ def _git_repo_name(cwd: str) -> str | None:
                   pass
               return None
       
      +    # Honcho enforces a 100-char limit on session IDs. Long gateway session keys
      +    # (Matrix "!room:server" + thread event IDs, Telegram supergroup reply
      +    # chains, Slack thread IDs with long workspace prefixes) can overflow this
      +    # limit after sanitization; the Honcho API then rejects every call for that
      +    # session with "session_id too long". See issue #13868.
      +    _HONCHO_SESSION_ID_MAX_LEN = 100
      +    _HONCHO_SESSION_ID_HASH_LEN = 8
      +
      +    @classmethod
      +    def _enforce_session_id_limit(cls, sanitized: str, original: str) -> str:
      +        """Truncate a sanitized session ID to Honcho's 100-char limit.
      +
      +        The common case (short keys) short-circuits with no modification.
      +        For over-limit keys, keep a prefix of the sanitized ID and append a
      +        deterministic ``-<sha256 prefix>`` suffix so two distinct long keys
      +        that share a leading segment don't collide onto the same truncated ID.
      +        The hash is taken over the *original* pre-sanitization key, so two
      +        inputs that sanitize to the same string still collide intentionally
      +        (same logical session), but two inputs that only share a prefix do not.
      +        """
      +        max_len = cls._HONCHO_SESSION_ID_MAX_LEN
      +        if len(sanitized) <= max_len:
      +            return sanitized
      +
      +        hash_len = cls._HONCHO_SESSION_ID_HASH_LEN
      +        digest = hashlib.sha256(original.encode("utf-8")).hexdigest()[:hash_len]
      +        # max_len - hash_len - 1 (for the '-' separator) chars of the sanitized
      +        # prefix, then '-<hash>'. Strip any trailing hyphen from the prefix so
      +        # the result doesn't double up on separators.
      +        prefix_len = max_len - hash_len - 1
      +        prefix = sanitized[:prefix_len].rstrip("-")
      +        return f"{prefix}-{digest}"
      +
           def resolve_session_name(
               self,
               cwd: str | None = None,
      @@ -566,7 +625,7 @@ def resolve_session_name(
               if gateway_session_key:
                   sanitized = re.sub(r'[^a-zA-Z0-9_-]+', '-', gateway_session_key).strip('-')
                   if sanitized:
      -                return sanitized
      +                return self._enforce_session_id_limit(sanitized, gateway_session_key)
       
               # per-session: inherit Hermes session_id (new Honcho session each run)
               if self.session_strategy == "per-session" and session_id:
      @@ -646,6 +705,11 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
               except Exception:
                   pass
       
      +    # Fall back to the default so an unconfigured install cannot hang
      +    # indefinitely on a stalled Honcho request.
      +    if resolved_timeout is None:
      +        resolved_timeout = _DEFAULT_HTTP_TIMEOUT
      +
           if resolved_base_url:
               logger.info("Initializing Honcho client (base_url: %s, workspace: %s)", resolved_base_url, config.workspace_id)
           else:
    • plugins/memory/honcho/session.py
      @@ -95,6 +95,7 @@ def __init__(
               self._config = config
               self._runtime_user_peer_name = runtime_user_peer_name
               self._cache: dict[str, HonchoSession] = {}
      +        self._cache_lock = threading.RLock()
               self._peers_cache: dict[str, Any] = {}
               self._sessions_cache: dict[str, Any] = {}
       
      @@ -273,17 +274,35 @@ def get_or_create(self, key: str) -> HonchoSession:
               Returns:
                   The session.
               """
      -        if key in self._cache:
      -            logger.debug("Local session cache hit: %s", key)
      -            return self._cache[key]
      -
      -        # Gateway sessions should use the runtime user identity when available.
      -        if self._runtime_user_peer_name:
      +        with self._cache_lock:
      +            if key in self._cache:
      +                logger.debug("Local session cache hit: %s", key)
      +                return self._cache[key]
      +
      +        # Determine peer IDs — no lock needed (read-only, no shared state mutation).
      +        # Gateway sessions normally use the runtime user identity (the
      +        # platform-native ID: Telegram UID, Discord snowflake, Slack user,
      +        # etc.) so multi-user bots scope memory per user.  For a single-user
      +        # deployment the config-supplied ``peer_name`` is an unambiguous
      +        # identity and we should keep it unified across platforms — see
      +        # #14984.  Opt into that with ``hosts.<host>.pinPeerName: true`` in
      +        # ``honcho.json`` (or root-level ``pinPeerName: true``).
      +        # `is True` (not `bool(...)`) is deliberate: several multi-user tests
      +        # pass a ``MagicMock`` for ``config`` where ``mock.pin_peer_name``
      +        # silently returns another MagicMock — truthy by default.  Requiring
      +        # strict ``True`` keeps pinning as opt-in even for callers that
      +        # haven't updated their mocks yet; real configs built via
      +        # ``from_global_config`` always produce a proper boolean.
      +        pin_peer_name = (
      +            self._config is not None
      +            and bool(getattr(self._config, "peer_name", None))
      +            and getattr(self._config, "pin_peer_name", False) is True
      +        )
      +        if self._runtime_user_peer_name and not pin_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
                   parts = key.split(":", 1)
                   channel = parts[0] if len(parts) > 1 else "default"
                   chat_id = parts[1] if len(parts) > 1 else key
      @@ -293,19 +312,14 @@ def get_or_create(self, key: str) -> HonchoSession:
                   self._config.ai_peer if self._config else "hermes-assistant"
               )
       
      -        # Sanitize session ID for Honcho
      +        # All expensive I/O outside the lock — Honcho's persistence is source of truth
               honcho_session_id = self._sanitize_id(key)
      -
      -        # Get or create peers
               user_peer = self._get_or_create_peer(user_peer_id)
               assistant_peer = self._get_or_create_peer(assistant_peer_id)
      -
      -        # Get or create Honcho session
               honcho_session, existing_messages = self._get_or_create_honcho_session(
                   honcho_session_id, user_peer, assistant_peer
               )
       
      -        # Convert Honcho messages to local format
               local_messages = []
               for msg in existing_messages:
                   role = "assistant" if msg.peer_id == assistant_peer_id else "user"
      @@ -313,10 +327,9 @@ def get_or_create(self, key: str) -> HonchoSession:
                       "role": role,
                       "content": msg.content,
                       "timestamp": msg.created_at.isoformat() if msg.created_at else "",
      -                "_synced": True,  # Already in Honcho
      +                "_synced": True,
                   })
       
      -        # Create local session wrapper with existing messages
               session = HonchoSession(
                   key=key,
                   user_peer_id=user_peer_id,
      @@ -325,7 +338,9 @@ def get_or_create(self, key: str) -> HonchoSession:
                   messages=local_messages,
               )
       
      -        self._cache[key] = session
      +        # Write to cache under lock — only one writer wins
      +        with self._cache_lock:
      +            self._cache[key] = session
               return session
       
           def _flush_session(self, session: HonchoSession) -> bool:
      @@ -356,13 +371,15 @@ def _flush_session(self, session: HonchoSession) -> bool:
                   for msg in new_messages:
                       msg["_synced"] = True
                   logger.debug("Synced %d messages to Honcho for %s", len(honcho_messages), session.key)
      -            self._cache[session.key] = session
      +            with self._cache_lock:
      +                self._cache[session.key] = session
                   return True
               except Exception as e:
                   for msg in new_messages:
                       msg["_synced"] = False
                   logger.error("Failed to sync messages to Honcho: %s", e)
      -            self._cache[session.key] = session
      +            with self._cache_lock:
      +                self._cache[session.key] = session
                   return False
       
           def _async_writer_loop(self) -> None:
      @@ -434,7 +451,9 @@ def flush_all(self) -> None:
               Called at session end for "session" write_frequency, or to force
               a sync before process exit regardless of mode.
               """
      -        for session in list(self._cache.values()):
      +        with self._cache_lock:
      +            sessions = list(self._cache.values())
      +        for session in sessions:
                   try:
                       self._flush_session(session)
                   except Exception as e:
      @@ -459,9 +478,10 @@ def shutdown(self) -> None:
       
           def delete(self, key: str) -> bool:
               """Delete a session from local cache."""
      -        if key in self._cache:
      -            del self._cache[key]
      -            return True
      +        with self._cache_lock:
      +            if key in self._cache:
      +                del self._cache[key]
      +                return True
               return False
       
           def new_session(self, key: str) -> HonchoSession:
      @@ -473,20 +493,25 @@ def new_session(self, key: str) -> HonchoSession:
               """
               import time
       
      -        # Remove old session from caches (but don't delete from Honcho)
      -        old_session = self._cache.pop(key, None)
      -        if old_session:
      -            self._sessions_cache.pop(old_session.honcho_session_id, None)
      -
      -        # Create new session with timestamp suffix
      -        timestamp = int(time.time())
      -        new_key = f"{key}:{timestamp}"
      -
      -        # get_or_create will create a fresh session
      -        session = self.get_or_create(new_key)
      -
      -        # Cache under the original key so callers find it by the expected name
      -        self._cache[key] = session
      +        # Hold the reentrant lock across get_or_create so a concurrent caller
      +        # can't observe the (old-popped, new-not-yet-inserted) gap and create
      +        # its own session under the raw key.  `_cache_lock` is an RLock so
      +        # nested reacquisition inside get_or_create is safe.
      +        with self._cache_lock:
      +            # Remove old session from caches (but don't delete from Honcho)
      +            old_session = self._cache.pop(key, None)
      +            if old_session:
      +                self._sessions_cache.pop(old_session.honcho_session_id, None)
      +
      +            # Create new session with timestamp suffix
      +            timestamp = int(time.time())
      +            new_key = f"{key}:{timestamp}"
      +
      +            # get_or_create will create a fresh session
      +            session = self.get_or_create(new_key)
      +
      +            # Cache under the original key so callers find it by the expected name
      +            self._cache[key] = session
       
               logger.info("Created new session for %s (honcho: %s)", key, session.honcho_session_id)
               return session
    • run_agent.py
      @@ -86,7 +86,7 @@
       
       
       # Agent internals extracted to agent/ package for modularity
      -from agent.memory_manager import build_memory_context_block, sanitize_context
      +from agent.memory_manager import StreamingContextScrubber, build_memory_context_block, sanitize_context
       from agent.retry_utils import jittered_backoff
       from agent.error_classifier import classify_api_error, FailoverReason
       from agent.prompt_builder import (
      @@ -1218,6 +1218,10 @@ def __init__(
               # Deferred paragraph break flag — set after tool iterations so a
               # single "\n\n" is prepended to the next real text delta.
               self._stream_needs_break = False
      +        # Stateful scrubber for <memory-context> spans split across stream
      +        # deltas (#5719).  sanitize_context() alone can't survive chunk
      +        # boundaries because the block regex needs both tags in one string.
      +        self._stream_context_scrubber = StreamingContextScrubber()
               # Visible assistant text already delivered through live token callbacks
               # during the current model response. Used to avoid re-sending the same
               # commentary when the provider later returns it as a completed interim
      @@ -6019,6 +6023,20 @@ def _call():
       
           def _reset_stream_delivery_tracking(self) -> None:
               """Reset tracking for text delivered during the current model response."""
      +        # Flush any benign partial-tag tail held by the context scrubber so it
      +        # reaches the UI before we clear state for the next model call.  If
      +        # the scrubber is mid-span, flush() drops the orphaned content.
      +        scrubber = getattr(self, "_stream_context_scrubber", None)
      +        if scrubber is not None:
      +            tail = scrubber.flush()
      +            if tail:
      +                callbacks = [cb for cb in (self.stream_delta_callback, self._stream_callback) if cb is not None]
      +                for cb in callbacks:
      +                    try:
      +                        cb(tail)
      +                    except Exception:
      +                        pass
      +                self._record_streamed_assistant_text(tail)
               self._current_streamed_assistant_text = ""
       
           def _record_streamed_assistant_text(self, text: str) -> None:
      @@ -6069,6 +6087,28 @@ def _fire_stream_delta(self, text: str) -> None:
               if getattr(self, "_stream_needs_break", False) and text and text.strip():
                   self._stream_needs_break = False
                   text = "\n\n" + text
      +            prepended_break = True
      +        else:
      +            prepended_break = False
      +        if isinstance(text, str):
      +            # Strip <think> blocks first (per-delta is safe for closed pairs; the
      +            # unterminated-tag path is handled downstream by stream_consumer).
      +            # Then feed through the stateful context scrubber so memory-context
      +            # spans split across chunks cannot leak to the UI (#5719).
      +            text = self._strip_think_blocks(text or "")
      +            scrubber = getattr(self, "_stream_context_scrubber", None)
      +            if scrubber is not None:
      +                text = scrubber.feed(text)
      +            else:
      +                # Defensive: legacy callers without the scrubber attribute.
      +                text = sanitize_context(text)
      +            # Only strip leading newlines on the first delta — mid-stream "\n" is legitimate markdown.
      +            if not prepended_break and not getattr(
      +                self, "_current_streamed_assistant_text", ""
      +            ):
      +                text = text.lstrip("\n")
      +        if not text:
      +            return
               callbacks = [cb for cb in (self.stream_delta_callback, self._stream_callback) if cb is not None]
               delivered = False
               for cb in callbacks:
      @@ -9592,16 +9632,6 @@ def run_conversation(
               if isinstance(persist_user_message, str):
                   persist_user_message = _sanitize_surrogates(persist_user_message)
       
      -        # Strip leaked <memory-context> blocks from user input.  When Honcho's
      -        # saveMessages persists a turn that included injected context, the block
      -        # can reappear in the next turn's user message via message history.
      -        # Stripping here prevents stale memory tags from leaking into the
      -        # conversation and being visible to the user or the model as user text.
      -        if isinstance(user_message, str):
      -            user_message = sanitize_context(user_message)
      -        if isinstance(persist_user_message, str):
      -            persist_user_message = sanitize_context(persist_user_message)
      -
               # Store stream callback for _interruptible_api_call to pick up
               self._stream_callback = stream_callback
               self._persist_user_message_idx = None
      @@ -9680,6 +9710,13 @@ def run_conversation(
               # Track user turns for memory flush and periodic nudge logic
               self._user_turn_count += 1
       
      +        # Reset the streaming context scrubber at the top of each turn so a
      +        # hung span from a prior interrupted stream can't taint this turn's
      +        # output.
      +        scrubber = getattr(self, "_stream_context_scrubber", None)
      +        if scrubber is not None:
      +            scrubber.reset()
      +
               # Preserve the original user message (no nudge injection).
               original_user_message = persist_user_message if persist_user_message is not None else user_message
       
      @@ -12711,7 +12748,6 @@ def _stop_spinner():
                               truncated_response_prefix = ""
                               length_continue_retries = 0
                           
      -                    # Strip <think> blocks from user-facing response (keep raw in messages for trajectory)
                           final_response = self._strip_think_blocks(final_response).strip()
                           
                           final_msg = self._build_assistant_message(assistant_message, finish_reason)
    • scripts/release.py
      @@ -557,6 +557,12 @@
           "[redacted:email]": "MorAlekss",
           "[redacted:email]": "ash",
           "[redacted:email]": "andrewhosf",
      +    # April 2026 Honcho bug-fix consolidation (#15381)
      +    "[redacted:email]": "HiddenPuppy",
      +    "[redacted:email]": "sasha-id",
      +    "[redacted:email]": "dontcallmejames",
      +    "[redacted:email]": "hekaru-agent",
      +    "[redacted:email]": "twozle",
       }
       
       
    • tests/agent/test_streaming_context_scrubber.py
      @@ -0,0 +1,211 @@
      +"""Unit tests for StreamingContextScrubber (agent/memory_manager.py).
      +
      +Regression coverage for #5719 — memory-context spans split across stream
      +deltas must not leak payload to the UI.  The one-shot sanitize_context()
      +regex can't survive chunk boundaries, so _fire_stream_delta routes deltas
      +through a stateful scrubber.
      +"""
      +
      +from agent.memory_manager import StreamingContextScrubber, sanitize_context
      +
      +
      +class TestStreamingContextScrubberBasics:
      +    def test_empty_input_returns_empty(self):
      +        s = StreamingContextScrubber()
      +        assert s.feed("") == ""
      +        assert s.flush() == ""
      +
      +    def test_plain_text_passes_through(self):
      +        s = StreamingContextScrubber()
      +        assert s.feed("hello world") == "hello world"
      +        assert s.flush() == ""
      +
      +    def test_complete_block_in_single_delta(self):
      +        """Regression: the one-shot test case from #13672 must still work."""
      +        s = StreamingContextScrubber()
      +        leaked = (
      +            "<memory-context>\n"
      +            "[System note: The following is recalled memory context, NOT new "
      +            "user input. Treat as informational background data.]\n\n"
      +            "## Honcho Context\nstale memory\n"
      +            "</memory-context>\n\nVisible answer"
      +        )
      +        out = s.feed(leaked) + s.flush()
      +        assert out == "\n\nVisible answer"
      +
      +    def test_open_and_close_in_separate_deltas_strips_payload(self):
      +        """The real streaming case: tag pair split across deltas."""
      +        s = StreamingContextScrubber()
      +        deltas = [
      +            "Hello ",
      +            "<memory-context>\npayload ",
      +            "more payload\n",
      +            "</memory-context> world",
      +        ]
      +        out = "".join(s.feed(d) for d in deltas) + s.flush()
      +        assert out == "Hello  world"
      +        assert "payload" not in out
      +
      +    def test_realistic_fragmented_chunks_strip_memory_payload(self):
      +        """Exact leak scenario from the reviewer's comment — 4 realistic chunks.
      +
      +        This is the case the original #13672 fix silently leaks on: the open
      +        tag, system note, payload, and close tag each arrive in their own
      +        delta because providers emit 1-80 char chunks.
      +        """
      +        s = StreamingContextScrubber()
      +        deltas = [
      +            "<memory-context>\n[System note: The following",
      +            " is recalled memory context, NOT new user input. "
      +            "Treat as informational background data.]\n\n",
      +            "## Honcho Context\nstale memory\n",
      +            "</memory-context>\n\nVisible answer",
      +        ]
      +        out = "".join(s.feed(d) for d in deltas) + s.flush()
      +        assert out == "\n\nVisible answer"
      +        # The system-note line and payload must never reach the UI.
      +        assert "System note" not in out
      +        assert "Honcho Context" not in out
      +        assert "stale memory" not in out
      +
      +    def test_open_tag_split_across_two_deltas(self):
      +        """The open tag itself arriving in two fragments."""
      +        s = StreamingContextScrubber()
      +        out = (
      +            s.feed("pre <memory")
      +            + s.feed("-context>leak</memory-context> post")
      +            + s.flush()
      +        )
      +        assert out == "pre  post"
      +        assert "leak" not in out
      +
      +    def test_close_tag_split_across_two_deltas(self):
      +        """The close tag arriving in two fragments."""
      +        s = StreamingContextScrubber()
      +        out = (
      +            s.feed("pre <memory-context>leak</memory")
      +            + s.feed("-context> post")
      +            + s.flush()
      +        )
      +        assert out == "pre  post"
      +        assert "leak" not in out
      +
      +
      +class TestStreamingContextScrubberPartialTagFalsePositives:
      +    def test_partial_open_tag_tail_emitted_on_flush(self):
      +        """Bare '<mem' at end of stream is not really a memory-context tag."""
      +        s = StreamingContextScrubber()
      +        out = s.feed("hello <mem") + s.feed("ory other") + s.flush()
      +        assert out == "hello <memory other"
      +
      +    def test_partial_tag_released_when_disambiguated(self):
      +        """A held-back partial tag that turns out to be prose gets released."""
      +        s = StreamingContextScrubber()
      +        # '< ' should not look like the start of any tag.
      +        out = s.feed("price < ") + s.feed("10 dollars") + s.flush()
      +        assert out == "price < 10 dollars"
      +
      +
      +class TestStreamingContextScrubberUnterminatedSpan:
      +    def test_unterminated_span_drops_payload(self):
      +        """Provider drops close tag — better to lose output than to leak."""
      +        s = StreamingContextScrubber()
      +        out = s.feed("pre <memory-context>secret never closed") + s.flush()
      +        assert out == "pre "
      +        assert "secret" not in out
      +
      +    def test_reset_clears_hung_span(self):
      +        """Cross-turn scrubber reset drops a hung span so next turn is clean."""
      +        s = StreamingContextScrubber()
      +        s.feed("pre <memory-context>half")
      +        s.reset()
      +        out = s.feed("clean text") + s.flush()
      +        assert out == "clean text"
      +
      +
      +class TestStreamingContextScrubberCaseInsensitivity:
      +    def test_uppercase_tags_still_scrubbed(self):
      +        s = StreamingContextScrubber()
      +        out = (
      +            s.feed("<MEMORY-CONTEXT>secret")
      +            + s.feed("</Memory-Context>visible")
      +            + s.flush()
      +        )
      +        assert out == "visible"
      +        assert "secret" not in out
      +
      +
      +class TestSanitizeContextUnchanged:
      +    """Smoke test that the one-shot sanitize_context still works for whole strings."""
      +
      +    def test_whole_block_still_sanitized(self):
      +        leaked = (
      +            "<memory-context>\n"
      +            "[System note: The following is recalled memory context, NOT new "
      +            "user input. Treat as informational background data.]\n"
      +            "payload\n"
      +            "</memory-context>\nVisible"
      +        )
      +        out = sanitize_context(leaked).strip()
      +        assert out == "Visible"
      +
      +
      +class TestStreamingContextScrubberCrossTurn:
      +    """A scrubber instance is reused across turns (per agent).  reset() must
      +    clear any held state so a partial-tag tail from turn N doesn't bleed
      +    into turn N+1's first delta."""
      +
      +    def test_reset_clears_held_partial_tag(self):
      +        s = StreamingContextScrubber()
      +        # Feed a partial open-tag prefix that gets held back as buffer.
      +        out_turn_1 = s.feed("answer<memo")
      +        assert out_turn_1 == "answer"
      +
      +        # Reset for next turn — buffer must clear.
      +        s.reset()
      +
      +        # New turn: plain text starting with a "<m" must NOT be treated as
      +        # the continuation of the held "<memo".
      +        out_turn_2 = s.feed("<marker>fresh content")
      +        assert out_turn_2 == "<marker>fresh content"
      +
      +    def test_reset_clears_in_span_state(self):
      +        s = StreamingContextScrubber()
      +        s.feed("text<memory-context>secret-tail")
      +        # Mid-span state held — without reset, subsequent text would be
      +        # discarded until we see </memory-context>.
      +        s.reset()
      +        out = s.feed("post-reset visible text")
      +        assert out == "post-reset visible text"
      +
      +
      +class TestBuildMemoryContextBlockWarnsOnViolation:
      +    """Providers must return raw context — not pre-wrapped.  When they do,
      +    we strip and warn so the buggy provider surfaces."""
      +
      +    def test_provider_emitting_wrapper_warns(self, caplog):
      +        import logging
      +        from agent.memory_manager import build_memory_context_block
      +
      +        prewrapped = (
      +            "<memory-context>\n"
      +            "[System note: ...]\n\n"
      +            "real fact\n"
      +            "</memory-context>"
      +        )
      +        with caplog.at_level(logging.WARNING, logger="agent.memory_manager"):
      +            out = build_memory_context_block(prewrapped)
      +
      +        assert any("pre-wrapped" in rec.message for rec in caplog.records)
      +        assert out.count("<memory-context>") == 1
      +        assert out.count("</memory-context>") == 1
      +
      +    def test_clean_provider_output_does_not_warn(self, caplog):
      +        import logging
      +        from agent.memory_manager import build_memory_context_block
      +
      +        with caplog.at_level(logging.WARNING, logger="agent.memory_manager"):
      +            out = build_memory_context_block("plain fact about user")
      +
      +        assert not any("pre-wrapped" in rec.message for rec in caplog.records)
      +        assert "plain fact about user" in out
    • tests/gateway/test_vision_memory_leak.py
      @@ -0,0 +1,80 @@
      +"""Tests for _enrich_message_with_vision — regression for #5719.
      +
      +The auxiliary vision LLM can echo system-prompt memory-context back into
      +its analysis output.  The boundary fix in gateway/run.py runs the generic
      +sanitize_context helper over the description so the fenced wrapper and
      +its system-note are removed before the description reaches the user.
      +
      +Plugin-specific header cleanup (e.g. "## Honcho Context") belongs at the
      +provider boundary, not in this shared gateway path.
      +"""
      +
      +import asyncio
      +import json
      +from unittest.mock import AsyncMock, patch
      +
      +import pytest
      +
      +
      [redacted:email]
      +def gateway_runner():
      +    """Minimal GatewayRunner stub with just the method under test bound."""
      +    from gateway.run import GatewayRunner
      +
      +    class _Stub:
      +        _enrich_message_with_vision = GatewayRunner._enrich_message_with_vision
      +
      +    return _Stub()
      +
      +
      +def _run(coro):
      +    return asyncio.get_event_loop().run_until_complete(coro) if False else asyncio.new_event_loop().run_until_complete(coro)
      +
      +
      +class TestEnrichMessageWithVision:
      +    def test_clean_description_passes_through(self, gateway_runner):
      +        """Vision output without leaked memory is embedded unchanged."""
      +        fake_result = json.dumps({
      +            "success": True,
      +            "analysis": "A photograph of a sunset over the ocean.",
      +        })
      +        with patch("tools.vision_tools.vision_analyze_tool", new=AsyncMock(return_value=fake_result)):
      +            out = _run(gateway_runner._enrich_message_with_vision("caption", ["/tmp/img.jpg"]))
      +        assert "sunset over the ocean" in out
      +
      +    def test_memory_context_fence_stripped(self, gateway_runner):
      +        """<memory-context>...</memory-context> fenced block is scrubbed."""
      +        leaked = (
      +            "<memory-context>\n"
      +            "[System note: The following is recalled memory context, NOT new "
      +            "user input. Treat as informational background data.]\n\n"
      +            "User details and preferences here.\n"
      +            "</memory-context>\n"
      +            "A photograph of a cat."
      +        )
      +        fake_result = json.dumps({"success": True, "analysis": leaked})
      +        with patch("tools.vision_tools.vision_analyze_tool", new=AsyncMock(return_value=fake_result)):
      +            out = _run(gateway_runner._enrich_message_with_vision("caption", ["/tmp/img.jpg"]))
      +        assert "photograph of a cat" in out
      +        assert "<memory-context>" not in out
      +        assert "User details and preferences" not in out
      +        assert "System note" not in out
      +
      +    def test_fenced_leak_stripped_plugin_header_preserved(self, gateway_runner):
      +        """The fenced wrapper is stripped; plugin-specific text outside the
      +        fence (e.g. a "## Honcho Context" header) is left to the plugin layer.
      +        Gateway core stays plugin-agnostic."""
      +        leaked = (
      +            "<memory-context>\n"
      +            "[System note: The following is recalled memory context, NOT new "
      +            "user input. Treat as informational background data.]\n"
      +            "fenced leak\n"
      +            "</memory-context>\n"
      +            "A photograph of a dog."
      +        )
      +        fake_result = json.dumps({"success": True, "analysis": leaked})
      +        with patch("tools.vision_tools.vision_analyze_tool", new=AsyncMock(return_value=fake_result)):
      +            out = _run(gateway_runner._enrich_message_with_vision("caption", ["/tmp/img.jpg"]))
      +        assert "photograph of a dog" in out
      +        assert "fenced leak" not in out
      +        assert "<memory-context>" not in out
    • tests/honcho_plugin/test_cli.py
      @@ -3,6 +3,103 @@
       from types import SimpleNamespace
       
       
      +class TestResolveApiKey:
      +    """Test _resolve_api_key with various config shapes."""
      +
      +    def test_returns_api_key_from_root(self, monkeypatch):
      +        import plugins.memory.honcho.cli as honcho_cli
      +        monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
      +        monkeypatch.delenv("HONCHO_API_KEY", raising=False)
      +        assert honcho_cli._resolve_api_key({"apiKey": "root-key"}) == "root-key"
      +
      +    def test_returns_api_key_from_host_block(self, monkeypatch):
      +        import plugins.memory.honcho.cli as honcho_cli
      +        monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
      +        monkeypatch.delenv("HONCHO_API_KEY", raising=False)
      +        cfg = {"hosts": {"hermes": {"apiKey": "host-key"}}, "apiKey": "root-key"}
      +        assert honcho_cli._resolve_api_key(cfg) == "host-key"
      +
      +    def test_returns_local_for_base_url_without_api_key(self, monkeypatch):
      +        import plugins.memory.honcho.cli as honcho_cli
      +        monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
      +        monkeypatch.delenv("HONCHO_API_KEY", raising=False)
      +        monkeypatch.delenv("HONCHO_BASE_URL", raising=False)
      +        cfg = {"baseUrl": "http://localhost:8000"}
      +        assert honcho_cli._resolve_api_key(cfg) == "local"
      +
      +    def test_returns_local_for_base_url_env_var(self, monkeypatch):
      +        import plugins.memory.honcho.cli as honcho_cli
      +        monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
      +        monkeypatch.delenv("HONCHO_API_KEY", raising=False)
      +        monkeypatch.setenv("HONCHO_BASE_URL", "http://10.0.0.5:8000")
      +        assert honcho_cli._resolve_api_key({}) == "local"
      +
      +    def test_returns_empty_when_nothing_configured(self, monkeypatch):
      +        import plugins.memory.honcho.cli as honcho_cli
      +        monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
      +        monkeypatch.delenv("HONCHO_API_KEY", raising=False)
      +        monkeypatch.delenv("HONCHO_BASE_URL", raising=False)
      +        assert honcho_cli._resolve_api_key({}) == ""
      +
      +    def test_rejects_garbage_base_url_without_scheme(self, monkeypatch):
      +        """Obvious non-URL literals in baseUrl (typos) must not pass the guard."""
      +        import plugins.memory.honcho.cli as honcho_cli
      +        monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
      +        monkeypatch.delenv("HONCHO_API_KEY", raising=False)
      +        monkeypatch.delenv("HONCHO_BASE_URL", raising=False)
      +        # Boolean literals, pure digits, and bare identifiers without
      +        # host-like punctuation are rejected.  Schemeless host:port-style
      +        # strings are accepted (see test_accepts_legacy_schemeless_host).
      +        for garbage in ("true", "false", "null", "1", "12345", "localhost"):
      +            assert honcho_cli._resolve_api_key({"baseUrl": garbage}) == "", \
      +                f"expected empty for garbage {garbage!r}"
      +
      +    def test_rejects_non_http_scheme_base_url(self, monkeypatch):
      +        """file:// / ftp:// / ws:// schemes are rejected as non-HTTP Honcho URLs.
      +
      +        Note: these DO contain ``.`` or ``:`` so they pass the schemeless
      +        host fallback.  That's acceptable — the Honcho SDK will still
      +        reject them when it tries to connect.  If tighter filtering is
      +        needed later, extend the lowered-literal blocklist or check the
      +        parsed scheme explicitly.
      +        """
      +        import plugins.memory.honcho.cli as honcho_cli
      +        monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
      +        monkeypatch.delenv("HONCHO_API_KEY", raising=False)
      +        monkeypatch.delenv("HONCHO_BASE_URL", raising=False)
      +        # file:/// parses with scheme='file' but empty netloc, so the
      +        # http/https guard rejects; the schemeless fallback also rejects
      +        # because 'file:' starts with a known-non-http scheme prefix.
      +        # ftp://host/ parses with scheme='ftp', netloc='host' — the
      +        # http/https guard rejects but the schemeless fallback accepts
      +        # because 'ftp://host/' contains ':' and '.'.  Behaviour is
      +        # intentionally lenient: SDK errors out with clearer message.
      +
      +    def test_accepts_https_base_url(self, monkeypatch):
      +        import plugins.memory.honcho.cli as honcho_cli
      +        monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
      +        monkeypatch.delenv("HONCHO_API_KEY", raising=False)
      +        monkeypatch.delenv("HONCHO_BASE_URL", raising=False)
      +        assert honcho_cli._resolve_api_key({"baseUrl": "https://honcho.example.com"}) == "local"
      +
      +    def test_accepts_legacy_schemeless_host(self, monkeypatch):
      +        """Legacy configs with schemeless host:port must not regress.
      +
      +        Before scheme validation landed, ``baseUrl: "localhost:8000"`` passed
      +        the truthy check and flowed through to the SDK.  The lenient
      +        schemeless fallback preserves that behaviour so self-hosters with
      +        older configs don't see spurious "no API key configured" errors.
      +        The SDK itself still rejects malformed URLs at connect time.
      +        """
      +        import plugins.memory.honcho.cli as honcho_cli
      +        monkeypatch.setattr(honcho_cli, "_host_key", lambda: "hermes")
      +        monkeypatch.delenv("HONCHO_API_KEY", raising=False)
      +        monkeypatch.delenv("HONCHO_BASE_URL", raising=False)
      +        for legacy in ("localhost:8000", "10.0.0.5:8000", "honcho.local:8080", "host.example.com"):
      +            assert honcho_cli._resolve_api_key({"baseUrl": legacy}) == "local", \
      +                f"expected local sentinel for legacy schemeless {legacy!r}"
      +
      +
       class TestCmdStatus:
           def test_reports_connection_failure_when_session_setup_fails(self, monkeypatch, capsys, tmp_path):
               import plugins.memory.honcho.cli as honcho_cli
    • tests/honcho_plugin/test_client.py
      @@ -14,7 +14,7 @@
           reset_honcho_client,
           resolve_active_host,
           resolve_config_path,
      -    GLOBAL_CONFIG_PATH,
      +    resolve_global_config_path,
           HOST,
       )
       
      @@ -360,7 +360,7 @@ def test_falls_back_to_global_when_no_local(self, tmp_path):
               with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), \
                    patch.object(Path, "home", return_value=fake_home):
                   result = resolve_config_path()
      -        assert result == GLOBAL_CONFIG_PATH
      +        assert result == fake_home / ".honcho" / "config.json"
       
           def test_falls_back_to_global_without_hermes_home_env(self, tmp_path):
               fake_home = tmp_path / "fakehome"
      @@ -370,7 +370,18 @@ def test_falls_back_to_global_without_hermes_home_env(self, tmp_path):
                    patch.object(Path, "home", return_value=fake_home):
                   os.environ.pop("HERMES_HOME", None)
                   result = resolve_config_path()
      -        assert result == GLOBAL_CONFIG_PATH
      +        assert result == fake_home / ".honcho" / "config.json"
      +
      +    def test_global_fallback_uses_home_at_call_time(self, tmp_path):
      +        fake_home = tmp_path / "fakehome"
      +        fake_home.mkdir()
      +        hermes_home = tmp_path / "hermes"
      +        hermes_home.mkdir()
      +
      +        with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), \
      +             patch.object(Path, "home", return_value=fake_home):
      +            assert resolve_global_config_path() == fake_home / ".honcho" / "config.json"
      +            assert resolve_config_path() == fake_home / ".honcho" / "config.json"
       
           def test_from_global_config_uses_local_path(self, tmp_path):
               hermes_home = tmp_path / "hermes"
      @@ -589,6 +600,28 @@ def test_hermes_config_timeout_override_used_when_config_timeout_missing(self):
               mock_honcho.assert_called_once()
               assert mock_honcho.call_args.kwargs["timeout"] == 88.0
       
      +    @pytest.mark.skipif(
      +        not importlib.util.find_spec("honcho"),
      +        reason="honcho SDK not installed"
      +    )
      +    def test_defaults_to_30s_when_no_timeout_configured(self):
      +        from plugins.memory.honcho.client import _DEFAULT_HTTP_TIMEOUT
      +
      +        fake_honcho = MagicMock(name="Honcho")
      +        cfg = HonchoClientConfig(
      +            api_key="[redacted:secret-assignment]",
      +            workspace_id="hermes",
      +            environment="production",
      +        )
      +
      +        with patch("honcho.Honcho", return_value=fake_honcho) as mock_honcho, \
      +             patch("hermes_cli.config.load_config", return_value={}):
      +            client = get_honcho_client(cfg)
      +
      +        assert client is fake_honcho
      +        mock_honcho.assert_called_once()
      +        assert mock_honcho.call_args.kwargs["timeout"] == _DEFAULT_HTTP_TIMEOUT
      +
           @pytest.mark.skipif(
               not importlib.util.find_spec("honcho"),
               reason="honcho SDK not installed"
      @@ -656,6 +689,82 @@ def test_gateway_key_sanitizes_special_chars(self):
               assert ":" not in result
       
       
      +class TestResolveSessionNameLengthLimit:
      +    """Regression tests for Honcho's 100-char session ID limit (issue #13868).
      +
      +    Long gateway session keys (Matrix room+event IDs, Telegram supergroup
      +    reply chains, Slack thread IDs with long workspace prefixes) can overflow
      +    Honcho's 100-char session_id limit after sanitization. Before this fix,
      +    every Honcho API call for those sessions 400'd with "session_id too long".
      +    """
      +
      +    HONCHO_MAX = 100
      +
      +    def test_short_gateway_key_unchanged(self):
      +        """Short keys must not get a hash suffix appended."""
      +        config = HonchoClientConfig()
      +        result = config.resolve_session_name(
      +            gateway_session_key="agent:main:telegram:dm:8439114563",
      +        )
      +        # Unchanged fast-path: sanitize only, no truncation, no hash suffix.
      +        assert result == "agent-main-telegram-dm-8439114563"
      +        assert len(result) <= self.HONCHO_MAX
      +
      +    def test_key_at_exact_limit_unchanged(self):
      +        """A sanitized key that is exactly 100 chars must be returned as-is."""
      +        key = "a" * self.HONCHO_MAX
      +        config = HonchoClientConfig()
      +        result = config.resolve_session_name(gateway_session_key=key)
      +        assert result == key
      +        assert len(result) == self.HONCHO_MAX
      +
      +    def test_long_gateway_key_truncated_to_limit(self):
      +        """An over-limit sanitized key must truncate to exactly 100 chars."""
      +        key = "!roomid:matrix.example.org|" + "$event_" + ("a" * 300)
      +        config = HonchoClientConfig()
      +        result = config.resolve_session_name(gateway_session_key=key)
      +        assert result is not None
      +        assert len(result) == self.HONCHO_MAX
      +
      +    def test_truncation_is_deterministic(self):
      +        """Same long key must always produce the same truncated session ID."""
      +        key = "matrix-" + ("a" * 300)
      +        config = HonchoClientConfig()
      +        first = config.resolve_session_name(gateway_session_key=key)
      +        second = config.resolve_session_name(gateway_session_key=key)
      +        assert first == second
      +
      +    def test_truncated_result_respects_char_allowlist(self):
      +        """Truncated result must still match Honcho's [a-zA-Z0-9_-] allowlist."""
      +        import re
      +        key = "slack:T12345:thread-reply:" + ("x" * 300) + ":with:colons:and:slashes/here"
      +        config = HonchoClientConfig()
      +        result = config.resolve_session_name(gateway_session_key=key)
      +        assert result is not None
      +        assert re.fullmatch(r"[a-zA-Z0-9_-]+", result)
      +
      +    def test_distinct_long_keys_do_not_collide(self):
      +        """Two long keys sharing a prefix must produce different truncated IDs."""
      +        prefix = "matrix:!room:example.org|" + "a" * 200
      +        key_a = prefix + "-suffix-alpha"
      +        key_b = prefix + "-suffix-beta"
      +        config = HonchoClientConfig()
      +        result_a = config.resolve_session_name(gateway_session_key=key_a)
      +        result_b = config.resolve_session_name(gateway_session_key=key_b)
      +        assert result_a != result_b
      +        assert len(result_a) == self.HONCHO_MAX
      +        assert len(result_b) == self.HONCHO_MAX
      +
      +    def test_truncated_result_has_hash_suffix(self):
      +        """Truncated IDs must end with '-<8 hex chars>' for collision resistance."""
      +        import re
      +        key = "matrix-" + ("a" * 300)
      +        config = HonchoClientConfig()
      +        result = config.resolve_session_name(gateway_session_key=key)
      +        # Last 9 chars: '-' + 8 hex chars.
      +        assert re.search(r"-[0-9a-f]{8}$", result)
      +
      +
       class TestResetHonchoClient:
           def test_reset_clears_singleton(self):
               import plugins.memory.honcho.client as mod
    • tests/honcho_plugin/test_empty_profile_hint.py
      @@ -0,0 +1,85 @@
      +"""Tests for honcho_profile's empty-card hint (#5137 follow-up)."""
      +
      +from __future__ import annotations
      +
      +import json
      +from unittest.mock import MagicMock
      +
      +from plugins.memory.honcho import HonchoMemoryProvider
      +
      +
      +def _make_provider(**cfg_overrides) -> HonchoMemoryProvider:
      +    provider = HonchoMemoryProvider()
      +    provider._manager = MagicMock()
      +    provider._manager.get_peer_card.return_value = []  # empty card
      +    provider._session_key = "agent:main:test"
      +    provider._session_initialized = True  # bypass the lazy _ensure_session() gate
      +    provider._cron_skipped = False
      +
      +    cfg = MagicMock()
      +    # Defaults match HonchoClientConfig defaults
      +    cfg.user_observe_me = cfg_overrides.get("user_observe_me", True)
      +    cfg.user_observe_others = cfg_overrides.get("user_observe_others", True)
      +    cfg.ai_observe_me = cfg_overrides.get("ai_observe_me", True)
      +    cfg.ai_observe_others = cfg_overrides.get("ai_observe_others", True)
      +    cfg.message_max_chars = 25000
      +    provider._config = cfg
      +
      +    provider._dialectic_cadence = cfg_overrides.get("dialectic_cadence", 1)
      +    provider._turn_count = cfg_overrides.get("turn_count", 5)
      +    return provider
      +
      +
      +class TestEmptyProfileHint:
      +    def test_returns_hint_not_bare_error_message(self):
      +        provider = _make_provider()
      +        raw = provider.handle_tool_call("honcho_profile", {})
      +        payload = json.loads(raw)
      +        assert payload["result"] == "No profile facts available yet."
      +        assert "hint" in payload
      +        assert "not an error" in payload["hint"].lower()
      +
      +    def test_hint_mentions_warmup_when_turn_count_below_cadence(self):
      +        provider = _make_provider(turn_count=1, dialectic_cadence=3)
      +        raw = provider.handle_tool_call("honcho_profile", {})
      +        payload = json.loads(raw)
      +        assert "turn" in payload["hint"].lower()
      +        assert "cadence" in payload["hint"].lower()
      +
      +    def test_hint_mentions_observation_when_fully_disabled_for_user(self):
      +        provider = _make_provider(user_observe_me=False, user_observe_others=False)
      +        raw = provider.handle_tool_call("honcho_profile", {"peer": "user"})
      +        payload = json.loads(raw)
      +        assert "observation is disabled" in payload["hint"].lower()
      +
      +    def test_hint_mentions_observation_when_fully_disabled_for_ai(self):
      +        provider = _make_provider(ai_observe_me=False, ai_observe_others=False)
      +        raw = provider.handle_tool_call("honcho_profile", {"peer": "ai"})
      +        payload = json.loads(raw)
      +        assert "observation is disabled" in payload["hint"].lower()
      +        assert "ai" in payload["hint"]
      +
      +    def test_hint_falls_back_to_generic_reason_when_no_specific_cause(self):
      +        """Mature session with observation on + enough turns = generic hint."""
      +        provider = _make_provider(turn_count=50, dialectic_cadence=1)
      +        raw = provider.handle_tool_call("honcho_profile", {})
      +        payload = json.loads(raw)
      +        assert "hint" in payload
      +        # Generic hint mentions self-hosted as a common cause
      +        assert any(word in payload["hint"].lower() for word in ("self-hosted", "dialectic"))
      +
      +    def test_hint_suggests_alternative_tools(self):
      +        provider = _make_provider()
      +        raw = provider.handle_tool_call("honcho_profile", {})
      +        payload = json.loads(raw)
      +        # User-facing suggestion to try honcho_reasoning or honcho_search
      +        assert "honcho_reasoning" in payload["hint"] or "honcho_search" in payload["hint"]
      +
      +    def test_populated_card_returns_card_without_hint(self):
      +        """Regression: a populated card should NOT trigger the hint path."""
      +        provider = _make_provider()
      +        provider._manager.get_peer_card.return_value = ["Fact 1", "Fact 2"]
      +        raw = provider.handle_tool_call("honcho_profile", {})
      +        payload = json.loads(raw)
      +        assert payload["result"] == ["Fact 1", "Fact 2"]
      +        assert "hint" not in payload
    • tests/honcho_plugin/test_pin_peer_name.py
      @@ -0,0 +1,307 @@
      +"""Tests for the ``pinPeerName`` config flag (#14984).
      +
      +By default, when Hermes runs under a gateway (Telegram, Discord, Slack, ...)
      +it passes the platform-native user ID as ``runtime_user_peer_name`` into
      +``HonchoSessionManager``.  That ID wins over any configured ``peer_name``
      +so multi-user bots scope memory per user.
      +
      +For a single-user personal deployment where the user connects over multiple
      +platforms, that default forks memory into one Honcho peer per platform
      +(Telegram UID, Discord snowflake, Slack user ID, ...).  The user asked for
      +an opt-in knob that pins the user peer to ``peer_name`` from ``honcho.json``
      +so the same person's memory stays unified regardless of which platform the
      +turn arrived on — ``hosts.<host>.pinPeerName: true`` (or root-level
      +``pinPeerName: true``).
      +
      +These tests exercise both the config parsing (``client.py::from_global_config``)
      +and the resolution order (``session.py::get_or_create``).  We stub the
      +Honcho API calls so we can assert the chosen ``user_peer_id`` without
      +touching the network.
      +"""
      +
      +import json
      +from unittest.mock import MagicMock
      +
      +import pytest
      +
      +from plugins.memory.honcho.client import HonchoClientConfig
      +from plugins.memory.honcho.session import HonchoSessionManager
      +
      +
      +# ---------------------------------------------------------------------------
      +# Config parsing
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestPinPeerNameConfigParsing:
      +    def test_default_is_false(self):
      +        """Default preserves existing behaviour — multi-user bots unaffected."""
      +        config = HonchoClientConfig()
      +        assert config.pin_peer_name is False
      +
      +    def test_root_level_true(self, tmp_path, monkeypatch):
      +        config_file = tmp_path / "honcho.json"
      +        config_file.write_text(json.dumps({
      +            "apiKey": "k",
      +            "peerName": "Igor",
      +            "pinPeerName": True,
      +        }))
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path / "isolated"))
      +
      +        config = HonchoClientConfig.from_global_config(config_path=config_file)
      +        assert config.pin_peer_name is True
      +        assert config.peer_name == "Igor"
      +
      +    def test_host_block_true(self, tmp_path, monkeypatch):
      +        """Host-level flag works the same as root-level."""
      +        config_file = tmp_path / "honcho.json"
      +        config_file.write_text(json.dumps({
      +            "apiKey": "k",
      +            "peerName": "Igor",
      +            "hosts": {
      +                "hermes": {"pinPeerName": True},
      +            },
      +        }))
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path / "isolated"))
      +
      +        config = HonchoClientConfig.from_global_config(config_path=config_file)
      +        assert config.pin_peer_name is True
      +
      +    def test_host_block_overrides_root(self, tmp_path, monkeypatch):
      +        """Host block wins over root — matches how every other flag behaves."""
      +        config_file = tmp_path / "honcho.json"
      +        config_file.write_text(json.dumps({
      +            "apiKey": "k",
      +            "peerName": "Igor",
      +            "pinPeerName": True,
      +            "hosts": {
      +                "hermes": {"pinPeerName": False},
      +            },
      +        }))
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path / "isolated"))
      +
      +        config = HonchoClientConfig.from_global_config(config_path=config_file)
      +        assert config.pin_peer_name is False, (
      +            "host-level pinPeerName=false must override root-level true, the "
      +            "same way every other flag in this config is resolved"
      +        )
      +
      +    def test_explicit_false_parses(self, tmp_path, monkeypatch):
      +        config_file = tmp_path / "honcho.json"
      +        config_file.write_text(json.dumps({
      +            "apiKey": "k",
      +            "peerName": "Igor",
      +            "pinPeerName": False,
      +        }))
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path / "isolated"))
      +
      +        config = HonchoClientConfig.from_global_config(config_path=config_file)
      +        assert config.pin_peer_name is False
      +
      +
      +# ---------------------------------------------------------------------------
      +# Peer resolution (the actual bug fix)
      +# ---------------------------------------------------------------------------
      +
      +
      +def _patch_manager_for_resolution_test(mgr: HonchoSessionManager) -> None:
      +    """Stub out the Honcho client so ``get_or_create`` doesn't try to talk
      +    to the network — we only care about the user_peer_id chosen before
      +    those calls happen.
      +    """
      +    fake_peer = MagicMock()
      +    mgr._get_or_create_peer = MagicMock(return_value=fake_peer)
      +    mgr._get_or_create_honcho_session = MagicMock(
      +        return_value=(MagicMock(), [])
      +    )
      +
      +
      +class TestPeerResolutionOrder:
      +    """Matrix of (runtime_id, pin_peer_name, peer_name) → expected user_peer_id."""
      +
      +    def _config(self, *, peer_name: str | None, pin_peer_name: bool) -> HonchoClientConfig:
      +        # The test doesn't need auth / Honcho — disable the provider so
      +        # the manager doesn't try to open a real client.
      +        return HonchoClientConfig(
      +            api_key="[redacted:secret-assignment]",
      +            peer_name=peer_name,
      +            pin_peer_name=pin_peer_name,
      +            enabled=False,
      +            write_frequency="turn",  # avoid spawning the async writer thread
      +        )
      +
      +    def test_runtime_wins_when_pin_is_false(self):
      +        """Regression guard: default behaviour must stay unchanged.
      +        Multi-user bots rely on the platform-native ID winning."""
      +        mgr = HonchoSessionManager(
      +            honcho=MagicMock(),
      +            config=self._config(peer_name="Igor", pin_peer_name=False),
      +            runtime_user_peer_name="86701400",  # e.g. Telegram UID
      +        )
      +        _patch_manager_for_resolution_test(mgr)
      +
      +        session = mgr.get_or_create("telegram:86701400")
      +        assert session.user_peer_id == "86701400", (
      +            "pin_peer_name=False is the multi-user default — the gateway's "
      +            "platform-native user ID must win so each user gets their own "
      +            "peer scope.  If this regresses, every Telegram/Discord/Slack "
      +            "bot immediately merges memory across users."
      +        )
      +
      +    def test_config_wins_when_pin_is_true(self):
      +        """The #14984 fix: single-user deployments opt into config pinning."""
      +        mgr = HonchoSessionManager(
      +            honcho=MagicMock(),
      +            config=self._config(peer_name="Igor", pin_peer_name=True),
      +            runtime_user_peer_name="86701400",  # Telegram pushes this in
      +        )
      +        _patch_manager_for_resolution_test(mgr)
      +
      +        session = mgr.get_or_create("telegram:86701400")
      +        assert session.user_peer_id == "Igor", (
      +            "With pinPeerName=true the user's configured peer_name must "
      +            "beat the platform-native runtime ID so memory stays unified "
      +            "across Telegram/Discord/Slack for the same person."
      +        )
      +
      +    def test_pin_noop_when_peer_name_missing(self):
      +        """Safety: pinPeerName alone (no peer_name) must not silently drop
      +        the runtime identity.  Without a configured peer_name there's
      +        nothing to pin to — fall back to runtime as before."""
      +        mgr = HonchoSessionManager(
      +            honcho=MagicMock(),
      +            config=self._config(peer_name=None, pin_peer_name=True),
      +            runtime_user_peer_name="86701400",
      +        )
      +        _patch_manager_for_resolution_test(mgr)
      +
      +        session = mgr.get_or_create("telegram:86701400")
      +        assert session.user_peer_id == "86701400", (
      +            "pin_peer_name=True with no peer_name set must not strip the "
      +            "runtime ID — otherwise the user peer would collapse to the "
      +            "session-key fallback and lose per-user scoping entirely"
      +        )
      +
      +    def test_runtime_missing_falls_back_to_peer_name(self):
      +        """CLI-mode (no gateway runtime identity) uses config peer_name —
      +        this path was already correct but the refactor shouldn't break it."""
      +        mgr = HonchoSessionManager(
      +            honcho=MagicMock(),
      +            config=self._config(peer_name="Igor", pin_peer_name=False),
      +            runtime_user_peer_name=None,
      +        )
      +        _patch_manager_for_resolution_test(mgr)
      +
      +        session = mgr.get_or_create("cli:local")
      +        assert session.user_peer_id == "Igor"
      +
      +    def test_everything_missing_falls_back_to_session_key(self):
      +        """Deepest fallback: no runtime identity, no peer_name, no pin.
      +        Must still produce a deterministic peer_id from the session key."""
      +        # Config with no peer_name and default pin_peer_name=False
      +        mgr = HonchoSessionManager(
      +            honcho=MagicMock(),
      +            config=self._config(peer_name=None, pin_peer_name=False),
      +            runtime_user_peer_name=None,
      +        )
      +        _patch_manager_for_resolution_test(mgr)
      +
      +        session = mgr.get_or_create("telegram:123")
      +        assert session.user_peer_id == "user-telegram-123"
      +
      +    def test_pin_does_not_affect_assistant_peer(self):
      +        """The flag only pins the USER peer — the assistant peer continues
      +        to come from ``ai_peer`` and must not be touched."""
      +        cfg = HonchoClientConfig(
      +            api_key="k",
      +            peer_name="Igor",
      +            pin_peer_name=True,
      +            ai_peer="hermes-assistant",
      +            enabled=False,
      +            write_frequency="turn",
      +        )
      +        mgr = HonchoSessionManager(
      +            honcho=MagicMock(),
      +            config=cfg,
      +            runtime_user_peer_name="86701400",
      +        )
      +        _patch_manager_for_resolution_test(mgr)
      +
      +        session = mgr.get_or_create("telegram:86701400")
      +        assert session.user_peer_id == "Igor"
      +        assert session.assistant_peer_id == "hermes-assistant"
      +
      +
      +class TestCrossPlatformMemoryUnification:
      +    """The user-visible outcome of the #14984 fix: the same physical user
      +    talking to Hermes via Telegram AND Discord should land on ONE peer
      +    (not two) when pinPeerName is opted in.
      +    """
      +
      +    def _config_pinned(self) -> HonchoClientConfig:
      +        return HonchoClientConfig(
      +            api_key="k",
      +            peer_name="Igor",
      +            pin_peer_name=True,
      +            enabled=False,
      +            write_frequency="turn",
      +        )
      +
      +    def test_telegram_and_discord_collapse_to_one_peer_when_pinned(self):
      +        """Single-user deployment: Telegram UID and Discord snowflake
      +        both resolve to the same configured peer_name."""
      +        # Telegram turn
      +        mgr_telegram = HonchoSessionManager(
      +            honcho=MagicMock(),
      +            config=self._config_pinned(),
      +            runtime_user_peer_name="86701400",
      +        )
      +        _patch_manager_for_resolution_test(mgr_telegram)
      +        telegram_session = mgr_telegram.get_or_create("telegram:86701400")
      +
      +        # Discord turn (separate manager instance — simulates a fresh
      +        # platform-adapter invocation)
      +        mgr_discord = HonchoSessionManager(
      +            honcho=MagicMock(),
      +            config=self._config_pinned(),
      +            runtime_user_peer_name="1348750102029926454",
      +        )
      +        _patch_manager_for_resolution_test(mgr_discord)
      +        discord_session = mgr_discord.get_or_create("discord:1348750102029926454")
      +
      +        assert telegram_session.user_peer_id == "Igor"
      +        assert discord_session.user_peer_id == "Igor"
      +        assert telegram_session.user_peer_id == discord_session.user_peer_id, (
      +            "cross-platform memory unification is the whole point of "
      +            "pinPeerName — both platforms must land on the same Honcho peer"
      +        )
      +
      +    def test_multiuser_default_keeps_platforms_separate(self):
      +        """Negative control: with pinPeerName=false (the default), two
      +        different platform IDs must produce two different peers so
      +        multi-user bots don't merge users."""
      +        cfg = HonchoClientConfig(
      +            api_key="k",
      +            peer_name="Igor",
      +            pin_peer_name=False,
      +            enabled=False,
      +            write_frequency="turn",
      +        )
      +        mgr_a = HonchoSessionManager(
      +            honcho=MagicMock(), config=cfg, runtime_user_peer_name="user_a",
      +        )
      +        mgr_b = HonchoSessionManager(
      +            honcho=MagicMock(), config=cfg, runtime_user_peer_name="user_b",
      +        )
      +        _patch_manager_for_resolution_test(mgr_a)
      +        _patch_manager_for_resolution_test(mgr_b)
      +
      +        sess_a = mgr_a.get_or_create("telegram:a")
      +        sess_b = mgr_b.get_or_create("telegram:b")
      +
      +        assert sess_a.user_peer_id == "user_a"
      +        assert sess_b.user_peer_id == "user_b"
      +        assert sess_a.user_peer_id != sess_b.user_peer_id, (
      +            "multi-user default MUST keep users separate — a regression "
      +            "here would silently merge unrelated users' memory"
      +        )
    • tests/honcho_plugin/test_session.py
      @@ -525,6 +525,39 @@ def test_honcho_conclude_rejects_whitespace_only_delete_id(self):
               assert parsed == {"error": "Exactly one of conclusion or delete_id must be provided."}
               provider._manager.delete_conclusion.assert_not_called()
       
      +    def test_sync_turn_strips_leaked_memory_context_before_honcho_ingest(self):
      +        provider = HonchoMemoryProvider()
      +        provider._session_key = "telegram:123"
      +        provider._manager = MagicMock()
      +        provider._cron_skipped = False
      +        provider._config = SimpleNamespace(message_max_chars=25000)
      +
      +        session = MagicMock()
      +        provider._manager.get_or_create.return_value = session
      +
      +        provider.sync_turn(
      +            (
      +                "hello\n\n"
      +                "<memory-context>\n"
      +                "[System note: The following is recalled memory context, NOT new user input. Treat as informational background data.]\n\n"
      +                "## Honcho Context\n"
      +                "stale memory\n"
      +                "</memory-context>"
      +            ),
      +            (
      +                "<memory-context>\n"
      +                "[System note: The following is recalled memory context, NOT new user input. Treat as informational background data.]\n\n"
      +                "## Honcho Context\n"
      +                "stale memory\n"
      +                "</memory-context>\n\n"
      +                "Visible answer"
      +            ),
      +        )
      +        provider._sync_thread.join(timeout=1.0)
      +
      +        assert session.add_message.call_args_list[0].args == ("user", "hello")
      +        assert session.add_message.call_args_list[1].args == ("assistant", "Visible answer")
      +
       
       # ---------------------------------------------------------------------------
       # Message chunking
    • tests/run_agent/test_run_agent.py
      @@ -1441,6 +1441,24 @@ def test_think_blocks_stripped_preserves_normal_content(self, agent):
               result = agent._build_assistant_message(msg, "stop")
               assert result["content"] == "No thinking here."
       
      +    def test_memory_context_in_stored_content_is_preserved(self, agent):
      +        """`_build_assistant_message` must not silently mutate model output
      +        containing literal <memory-context> markers — that's legitimate text
      +        (e.g. documentation, code) that the model may emit.  Streaming-path
      +        leak prevention is handled by StreamingContextScrubber upstream."""
      +        original = (
      +            "<memory-context>\n"
      +            "[System note: The following is recalled memory context, NOT new user input. Treat as informational background data.]\n\n"
      +            "## Honcho Context\n"
      +            "stale memory\n"
      +            "</memory-context>\n\n"
      +            "Visible answer"
      +        )
      +        msg = _mock_assistant_msg(content=original)
      +        result = agent._build_assistant_message(msg, "stop")
      +        assert "<memory-context>" in result["content"]
      +        assert "Visible answer" in result["content"]
      +
           def test_unterminated_think_block_stripped(self, agent):
               """Unterminated <think> block (MiniMax / NIM dropped close tag) is
               fully stripped from stored content."""
      @@ -4753,21 +4771,21 @@ def test_no_unreachable_max_retries_after_backoff(self):
       
       
       class TestMemoryContextSanitization:
      -    """run_conversation() must strip leaked <memory-context> blocks from user input."""
      +    """sanitize_context() helper correctness — used at provider boundaries."""
       
      -    def test_memory_context_stripped_from_user_message(self):
      -        """Verify that <memory-context> blocks are removed before the message
      -        enters the conversation loop — prevents stale Honcho injection from
      -        leaking into user text."""
      +    def test_user_message_is_not_mutated_by_run_conversation(self):
      +        """User input must reach run_conversation untouched — if a user types
      +        a literal <memory-context> tag we don't silently delete their text.
      +        The streaming scrubber + plugin-side scrub cover real leak paths."""
               import inspect
               src = inspect.getsource(AIAgent.run_conversation)
      -        # The sanitize_context call must appear in run_conversation's preamble
      -        assert "sanitize_context(user_message)" in src
      -        assert "sanitize_context(persist_user_message)" in src
      +        assert "sanitize_context(user_message)" not in src
      +        assert "sanitize_context(persist_user_message)" not in src
       
           def test_sanitize_context_strips_full_block(self):
      -        """End-to-end: a user message with an embedded memory-context block
      -        is cleaned to just the actual user text."""
      +        """Helper-level: a string with an embedded memory-context block is
      +        cleaned to just the surrounding text.  Used by build_memory_context_block
      +        (input-validation) and by plugins on their own backend boundary."""
               from agent.memory_manager import sanitize_context
               user_text = "how is the honcho working"
               injected = (
    • tests/run_agent/test_run_agent_codex_responses.py
      @@ -1115,6 +1115,141 @@ def failing_callback(_text):
           }
       
       
      +def test_interim_commentary_preserves_assistant_content(monkeypatch):
      +    """Interim commentary must not silently mutate assistant text containing
      +    literal <memory-context> markers — that's legitimate model output (docs,
      +    code).  Streaming-path leak prevention happens delta-by-delta upstream."""
      +    agent = _build_agent(monkeypatch)
      +    observed = {}
      +    agent.interim_assistant_callback = lambda text, *, already_streamed=False: observed.update(
      +        {"text": text, "already_streamed": already_streamed}
      +    )
      +
      +    content = (
      +        "<memory-context>\n"
      +        "[System note: The following is recalled memory context, NOT new user input. Treat as informational background data.]\n\n"
      +        "## Honcho Context\n"
      +        "stale memory\n"
      +        "</memory-context>\n\n"
      +        "I'll inspect the repo structure first."
      +    )
      +
      +    agent._emit_interim_assistant_message({"role": "assistant", "content": content})
      +
      +    assert "<memory-context>" in observed["text"]
      +    assert "I'll inspect the repo structure first." in observed["text"]
      +
      +
      +def test_stream_delta_strips_leaked_memory_context(monkeypatch):
      +    agent = _build_agent(monkeypatch)
      +    observed = []
      +    agent.stream_delta_callback = observed.append
      +
      +    leaked = (
      +        "<memory-context>\n"
      +        "[System note: The following is recalled memory context, NOT new user input. Treat as informational background data.]\n\n"
      +        "## Honcho Context\n"
      +        "stale memory\n"
      +        "</memory-context>\n\n"
      +        "Visible answer"
      +    )
      +
      +    agent._fire_stream_delta(leaked)
      +
      +    assert observed == ["Visible answer"]
      +
      +
      +def test_stream_delta_strips_leaked_memory_context_across_chunks(monkeypatch):
      +    """Regression for #5719 — the real streaming case.
      +
      +    Providers typically emit 1-80 char chunks, so the memory-context open
      +    tag, system-note line, payload, and close tag each arrive in separate
      +    deltas.  The per-delta sanitize_context() regex cannot survive that
      +    — only a stateful scrubber can.  None of the payload, system-note
      +    text, or "## Honcho Context" header may reach the delta callback.
      +    """
      +    agent = _build_agent(monkeypatch)
      +    observed = []
      +    agent.stream_delta_callback = observed.append
      +
      +    deltas = [
      +        "<memory-context>\n[System note: The following",
      +        " is recalled memory context, NOT new user input. ",
      +        "Treat as informational background data.]\n\n",
      +        "## Honcho Context\n",
      +        "stale memory about eri\n",
      +        "</memory-context>\n\n",
      +        "Visible answer",
      +    ]
      +    for d in deltas:
      +        agent._fire_stream_delta(d)
      +
      +    combined = "".join(observed)
      +    assert "Visible answer" in combined
      +    # None of the leaked payload may surface.
      +    assert "System note" not in combined
      +    assert "Honcho Context" not in combined
      +    assert "stale memory" not in combined
      +    assert "<memory-context>" not in combined
      +    assert "</memory-context>" not in combined
      +
      +
      +def test_stream_delta_scrubber_resets_between_turns(monkeypatch):
      +    """An unterminated span from a prior turn must not taint the next turn."""
      +    agent = _build_agent(monkeypatch)
      +
      +    # Simulate a hung span carried over — directly populate the scrubber.
      +    agent._stream_context_scrubber.feed("pre <memory-context>leaked")
      +
      +    # Normally run_conversation() resets the scrubber at turn start.
      +    agent._stream_context_scrubber.reset()
      +
      +    observed = []
      +    agent.stream_delta_callback = observed.append
      +    agent._fire_stream_delta("clean new turn text")
      +    assert "".join(observed) == "clean new turn text"
      +
      +
      +def test_stream_delta_preserves_mid_stream_leading_newlines(monkeypatch):
      +    """Mid-stream leading newlines must survive — they are legitimate
      +    markdown (lists, code fences, paragraph breaks).  Stripping them
      +    based on chunk boundaries silently breaks formatting.
      +
      +    Only the very first delta of a stream gets leading-newlines stripped
      +    (so stale provider preamble doesn't leak); after that, deltas are
      +    emitted verbatim.
      +    """
      +    agent = _build_agent(monkeypatch)
      +    observed = []
      +    agent.stream_delta_callback = observed.append
      +
      +    # First delta delivers text — strips its own leading "\n" once.
      +    agent._fire_stream_delta("\nHere is a list:")
      +    # Second delta starts with "\n- item" — must NOT be stripped.
      +    agent._fire_stream_delta("\n- first")
      +    agent._fire_stream_delta("\n- second")
      +
      +    combined = "".join(observed)
      +    assert combined == "Here is a list:\n- first\n- second"
      +
      +
      +def test_stream_delta_preserves_code_fence_newlines(monkeypatch):
      +    """Code blocks span multiple deltas.  A "\\n```python\\n" boundary
      +    is the canonical case where stripping leading newlines corrupts output."""
      +    agent = _build_agent(monkeypatch)
      +    observed = []
      +    agent.stream_delta_callback = observed.append
      +
      +    agent._fire_stream_delta("Here is the code:")
      +    agent._fire_stream_delta("\n```python\n")
      +    agent._fire_stream_delta("print('hi')\n")
      +    agent._fire_stream_delta("```\n")
      +
      +    combined = "".join(observed)
      +    assert "```python\n" in combined
      +    assert combined.startswith("Here is the code:\n```python\n")
      +
      +
       def test_run_conversation_codex_continues_after_commentary_phase_message(monkeypatch):
           agent = _build_agent(monkeypatch)
           responses = [
    • tests/test_hermes_state.py
      @@ -258,6 +258,24 @@ def test_finish_reason_stored(self, db):
               messages = db.get_messages("s1")
               assert messages[0]["finish_reason"] == "stop"
       
      +    def test_get_messages_as_conversation_strips_leaked_memory_context(self, db):
      +        db.create_session(session_id="s1", source="cli")
      +        db.append_message(
      +            "s1",
      +            role="assistant",
      +            content=(
      +                "<memory-context>\n"
      +                "[System note: The following is recalled memory context, NOT new user input. Treat as informational background data.]\n\n"
      +                "## Honcho Context\n"
      +                "stale memory\n"
      +                "</memory-context>\n\n"
      +                "Visible answer"
      +            ),
      +        )
      +
      +        conv = db.get_messages_as_conversation("s1")
      +        assert conv == [{"role": "assistant", "content": "Visible answer"}]
      +
           def test_reasoning_persisted_and_restored(self, db):
               """Reasoning text is stored for assistant messages and restored by
               get_messages_as_conversation() so providers receive coherent multi-turn

3 PRs closed without merging

From the close comments: superseded by another PR 2 · done elsewhere 1

  • #15162 fix(honcho): pinPeerName opt-in keeps memory unified across platforms (#14984) · superseded by another PR → #15381, #14984
  • #15340 fix(honcho): pinPeerName opt-in keeps memory unified across platforms · superseded by another PR → #15381
  • #43086 fix(plugins): stop Honcho minting a new peer per user-addressing name · done elsewhere → 2f80ae0, #131301
Closed issues · 2
  • Closed#14984honcho: peerName in config should win over platform user ID for single-user setups
  • Closed#42980[Bug]: Honcho memory tool keeps creating new peers for the same user
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.29: 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

Subsystem
agent
Merged PRs
#15381
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
27 Apr 2026

For your agent

The same facts, typed.