# NousResearch/hermes-agent · Honcho client rebuild on timeout config changes

## Open now

- Nothing open on GitHub.

These items address a bug where the Honcho client behaves as a per-process singleton, causing timeout configuration changes to be ignored until restart. A fix rebuilds the client when timeout config changes, alongside several related community recall and config fixes.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #66052. Verify it addresses your case.
- **Recommended:** read PR #66052 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #57347: Honcho client is a per-process singleton — timeout config changes ignored until process restart

## Prior & competing PRs

- **#66052: fix(honcho): salvage five community recall/config fixes onto current main**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `plugins/memory/honcho/__init__.py`, `plugins/memory/honcho/cli.py`, `plugins/memory/honcho/client.py`, `plugins/memory/honcho/session.py`, `scripts/release.py`, `tests/honcho_plugin/test_client.py`, `tests/honcho_plugin/test_session.py`
  - fix hunks:
  - `plugins/memory/honcho/__init__.py`

    ```diff
    @@ -125,13 +125,26 @@
                     "type": "string",
                     "description": (
                         "Override the default reasoning depth. "
    -                    "Omit to use the configured default (typically low). "
    -                    "Guide:\n"
    -                    "- minimal: quick factual lookups (name, role, simple preference)\n"
    -                    "- low: straightforward questions with clear answers\n"
    -                    "- medium: multi-aspect questions requiring synthesis across observations\n"
    -                    "- high: complex behavioral patterns, contradictions, deep analysis\n"
    -                    "- max: thorough audit-level analysis, leave no stone unturned"
    +                    "Omit to use the configured default (typically low).\n"
    +                    "reasoning_level parameter guide:\n"
    +                    "- minimal: use ONLY for a single quick factual lookup (e.g. "
    +                    "'what is the user's name'). Honcho hard-caps this tier's output "
    +                    "at 250 tokens combined with the model's own hidden reasoning "
    +                    "tokens — a multi-part answer can get cut off mid-thought before "
    +                    "it even reaches the final-answer phase, especially on models "
    +                    "with reasoning/thinking enabled.\n"
    +                    "- low/medium/high/max: use for anything requiring a synthesized, "
    +                    "multi-fact, or summary-style answer (e.g. 'summarize known facts "
    +                    "about this peer', 'what are their communication preferences'). "
    +                    "These tiers have no output-token cap of their own (fall back to "
    +                    "Honcho's 8192-token global default), so they don't have "
    +                    "minimal's cutoff failure mode.\n"
    +                    "  - low: straightforward questions with clear answers\n"
    +                    "  - medium: multi-aspect questions requiring synthesis across observations\n"
    +                    "  - high: complex behavioral patterns, contradictions, deep analysis\n"
    +                    "  - max: thorough audit-level analysis, leave no stone unturned\n"
    +                    "Default to at least 'low' unless the query is genuinely a single "
    +                    "fact lookup."
                     ),
                     "enum": ["minimal", "low", "medium", "high", "max"],
                 },
    ```

    ```diff
    @@ -1437,7 +1450,7 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
                     return json.dumps({"result": card})
     
                 elif tool_name == "honcho_search":
    -                query = args.get("query", "")
    +                query = (args.get("query") or "").strip()
                     if not query:
                         return tool_error("Missing required parameter: query")
                     max_tokens = [redacted:secret-assignment]"max_tokens", 800)), 2000)
    ```

    ```diff
    @@ -1450,7 +1463,7 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
                     return json.dumps({"result": result})
     
                 elif tool_name == "honcho_reasoning":
    -                query = args.get("query", "")
    +                query = (args.get("query") or "").strip()
                     if not query:
                         return tool_error("Missing required parameter: query")
                     peer = args.get("peer", "user")
    ```
  - `plugins/memory/honcho/cli.py`

    ```diff
    @@ -1099,7 +1099,7 @@ def cmd_status(args) -> None:
         print(f"  Recall mode:    {hcfg.recall_mode}")
         print(f"  Context budget: {hcfg.context_tokens or '(uncapped)'} tokens")
         raw = getattr(hcfg, "raw", None) or {}
    -    dialectic_cadence = raw.get("dialecticCadence") or 1
    +    dialectic_cadence = getattr(hcfg, "dialectic_cadence", None) or raw.get("dialecticCadence") or 1
         print(f"  Dialectic cad:  every {dialectic_cadence} turn{'s' if dialectic_cadence != 1 else ''}")
         reasoning_cap = raw.get("reasoningLevelCap") or hcfg.reasoning_level_cap
         heuristic_on = "on" if hcfg.reasoning_heuristic else "off"
    ```
  - `plugins/memory/honcho/client.py`

    ```diff
    @@ -17,8 +17,10 @@
     import os
     import logging
     import hashlib
    +import ipaddress
     from dataclasses import dataclass, field
     from pathlib import Path
    +from urllib.parse import urlparse
     
     from hermes_constants import get_hermes_home
     from hermes_cli.profiles import _get_default_hermes_home
    ```

    ```diff
    @@ -56,8 +58,9 @@ def resolve_active_host() -> str:
     
         Resolution order:
           1. HERMES_HONCHO_HOST env var (explicit override)
    -      2. Active profile name via profiles system -> ``hermes.<profile>``
    -      3. Fallback: ``"hermes"`` (default profile)
    +      2. Active profile name via profiles system -> ``hermes_<profile>``
    +      3. defaultHost from the active config, but only for the default profile
    +      4. Fallback: ``"hermes"`` (default profile)
         """
         explicit = os.environ.get("HERMES_HONCHO_HOST", "").strip()
         if explicit:
    ```

    ```diff
    @@ -66,10 +69,26 @@ def resolve_active_host() -> str:
         try:
             from hermes_cli.profiles import get_active_profile_name
             profile = get_active_profile_name()
    -        return profile_host_key(profile)
    +        profile_host = profile_host_key(profile)
         except Exception:
    -        pass
    -    return HOST
    +        profile_host = HOST
    +
    +    # Honcho's generic config can carry a defaultHost (for example "local"),
    +    # but applying it before profile resolution makes every named Hermes
    +    # profile share that same host.  Keep named profiles isolated; only the
    +    # default Hermes profile may opt into the config's default host.
    +    if profile_host == HOST:
    +        try:
    +            path = resolve_config_path()
    +            if path.exists():
    +                raw = json.loads(path.read_text(encoding="utf-8"))
    +                default_host = str(raw.get("defaultHost", "")).strip()
    +                if default_host:
    +                    return default_host
    +        except Exception:
    +            pass
    +
    +    return profile_host
     
     
     def resolve_global_config_path() -> Path:
    ```

    ```diff
    @@ -226,6 +245,44 @@ def _parse_dialectic_depth_levels(host_val, root_val, depth: int) -> list[str] |
     _DEFAULT_HTTP_TIMEOUT = 30.0
     
     
    +def _is_local_base_url(base_url: str | None) -> bool:
    +    """Return True for loopback/LAN/VPN self-hosted Honcho URLs.
    +
    +    Local Honcho deployments can run without auth, but the SDK requires a
    +    non-empty api_key argument.  Treat loopback plus RFC1918/link-local/ULA
    +    and carrier-grade-NAT IPs as local so LAN/VPN URLs such as
    +    ``http://192.168.2.112:8000`` get the same placeholder-key behavior as
    +    localhost.
    +    """
    +    if not base_url:
    +        return False
    +
    +    try:
    +        parsed = urlparse(base_url)
    +        host = (parsed.hostname or "").strip().lower()
    +    except Exception:
    +        host = ""
    +
    +    if host in {"localhost", "127.0.0.1", "::1"}:
    +        return True
    +    if not host:
    +        return False
    +
    +    try:
    +        ip = ipaddress.ip_address(host)
    +    except ValueError:
    +        return False
    +
    +    if ip.is_loopback or ip.is_private or ip.is_link_local:
    +        return True
    +
    +    # Tailscale/other VPN setups often sit in carrier-grade NAT space.
    +    if ip.version == 4 and ipaddress.ip_address("100.64.0.0") <= ip <= ipaddress.ip_address("100.127.255.255"):
    +        return True
    +
    +    return False
    +
    +
     def _resolve_optional_float(*values: Any) -> float | None:
         """Return the first non-empty value coerced to a positive float."""
         for value in values:
    ```

    ```diff
    @@ -798,6 +855,50 @@ def resolve_session_name(
     
     
     _honcho_client_slot: SingletonSlot = SingletonSlot()
    +_cached_timeout: float | None = None
    +# Memo for the config.yaml-derived timeout, keyed on the file's mtime_ns so
    +# the staleness check on every get_honcho_client() call costs one stat()
    +# instead of a full YAML load. (None, None) = not yet populated.
    +_config_timeout_memo: tuple[int | None, float | None] = (None, None)
    +
    +
    +def _config_yaml_timeout() -> float | None:
    +    """Read honcho.timeout / honcho.request_timeout from config.yaml, memoized on mtime."""
    +    global _config_timeout_memo
    +    try:
    +        from hermes_constants import get_hermes_home
    +
    +        cfg_path = get_hermes_home() / "config.yaml"
    +        try:
    +            mtime_ns: int | None = cfg_path.stat().st_mtime_ns
    +        except OSError:
    +            mtime_ns = None
    +        if _config_timeout_memo[0] is not None and _config_timeout_memo[0] == mtime_ns:
    +            return _config_timeout_memo[1]
    +
    +        from hermes_cli.config import load_config
    +
    +        honcho_cfg = load_config().get("honcho", {})
    +        timeout = None
    +        if isinstance(honcho_cfg, dict):
    +            timeout = _resolve_optional_float(
    +                honcho_cfg.get("timeout"),
    +                honcho_cfg.get("request_timeout"),
    +            )
    +        _config_timeout_memo = (mtime_ns, timeout)
    +        return timeout
    +    except Exception:
    +        return None
    +
    +
    +def _resolve_timeout_from_sources(config: HonchoClientConfig | None) -> float:
    +    """Resolve the effective timeout from env, config.yaml, and the explicit config."""
    +    timeout = config.timeout if config is not None else None
    +    if timeout is None:
    +        timeout = _resolve_optional_float(os.environ.get("HONCHO_TIMEOUT"))
    +    if timeout is None:
    +        timeout = _config_yaml_timeout()
    +    return timeout if timeout is not None else _DEFAULT_HTTP_TIMEOUT
     
     
     def _apply_fresh_oauth_token(config: HonchoClientConfig) -> None:
    ```

    ```diff
    @@ -843,10 +944,20 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
         first calls (double-checked locking via ``SingletonSlot``), so racing
         threads can't each construct a client and leak the loser's connection.
         """
    +    global _cached_timeout
         cached = _honcho_client_slot.peek()
         if cached is not None:
    -        _refresh_cached_oauth(cached, config)
    -        return cached
    +        # Detect timeout config changes in long-lived processes (gateway,
    +        # dashboard).  If the user changed the timeout after the client was
    +        # built, rebuild with the new value.
    +        new_timeout = _resolve_timeout_from_sources(config)
    +        if new_timeout != _cached_timeout:
    +            _honcho_client_slot.reset()
    +            _cached_timeout = None
    +            cached = None
    +        else:
    +            _refresh_cached_oauth(cached, config)
    +            return cached
     
         if config is None:
             config = HonchoClientConfig.from_global_config()
    ```

    ```diff
    @@ -922,16 +1033,12 @@ def _build() -> "Honcho":
             # For local: only use config.api_key if the host block explicitly
             # sets apiKey (meaning the user wants local auth). Otherwise skip
             # the stored key -- it's likely a cloud key that would break local.
    -        _is_local = resolved_base_url and (
    -            "localhost" in resolved_base_url
    -            or "127.0.0.1" in resolved_base_url
    -            or "::1" in resolved_base_url
    -        )
    +        _is_local = _is_local_base_url(resolved_base_url)
             if _is_local:
                 # Check if the host block has its own apiKey (explicit local auth).
    -            # Auth-skipping is loopback-only: a stored key is likely a cloud key
    -            # that would break a no-auth local server, so we substitute the SDK's
    -            # required-non-empty placeholder unless the host block opts in.
    +            # For local/LAN/VPN self-hosts, a stored root key is likely a cloud
    +            # key that would break a no-auth local server, so we substitute the
    +            # SDK's required-non-empty placeholder unless the host block opts in.
                 _raw = config.raw or {}
                 _host_block = (_raw.get("hosts") or {}).get(config.host, {})
                 _host_has_key = bool(_host_block.get("apiKey"))
    ```

    ```diff
    @@ -961,6 +1068,8 @@ def _build() -> "Honcho":
             if resolved_timeout is not None:
                 kwargs["timeout"] = resolved_timeout
     
    +        global _cached_timeout
    +        _cached_timeout = resolved_timeout
             return Honcho(**kwargs)
     
         return _honcho_client_slot.get(_build)
    ```

    ```diff
    @@ -968,4 +1077,7 @@ def _build() -> "Honcho":
     
     def reset_honcho_client() -> None:
         """Reset the Honcho client singleton (useful for testing)."""
    +    global _cached_timeout, _config_timeout_memo
         _honcho_client_slot.reset()
    +    _cached_timeout = None
    +    _config_timeout_memo = (None, None)
    ```
  - `plugins/memory/honcho/session.py`

    ```diff
    @@ -742,7 +742,8 @@ def get_prefetch_context(self, session_key: str, user_message: str | None = None
                 logger.debug("Failed to fetch session summary from Honcho: %s", e)
     
             try:
    -            user_ctx = self._fetch_peer_context(session.user_peer_id, search_query=user_message or None, target=session.user_peer_id)
    +            observer_peer_id, target_peer_id = self._resolve_observer_target(session, "user")
    +            user_ctx = self._fetch_peer_context(observer_peer_id, search_query=user_message or None, target=target_peer_id or session.user_peer_id)
                 result["representation"] = user_ctx["representation"]
                 result["card"] = "\n".join(user_ctx["card"])
             except Exception as e:
    ```
  - `scripts/release.py`

    ```diff
    @@ -53,6 +53,9 @@
         "[redacted:email]": "rlaehddus302",  # PR #61985 salvage (gateway: secondary-adapter auth callback profile)
         "[redacted:email]": "aguung",  # PR #64461 salvage (gateway: multiplex secret_scope for authz/Slack/webhooks)
         "[redacted:email]": "jtstothard",  # PR #63256 salvage (gateway: multiplex secondary adapter config validation)
    +    "[redacted:email]": "fjlaowan1983",  # PR #11256 salvage (honcho: reject whitespace-only reasoning queries)
    +    "RainbowAndSun@users.noreply.github.com": "RainbowAndSun",  # PR #62982 salvage (honcho: observer target in prefetch context)
    +    "[redacted:email]": "Elektrofussel",  # PR #61675 salvage (honcho: defaultHost + private-range local URL detection)
         "[redacted:email]": "SAMBAS123",  # PR #64986 salvage (gateway: multiplex primary bot token scope)
         "[redacted:email]": "kocaemre",  # PR #36051 salvage (docs: audit round 3 code/doc reconciliation)
         "[redacted:email]": "m1qaweb",  # PR #29290 salvage (gateway: strip /queue prefix when idle)
    ```
  - `tests/honcho_plugin/test_client.py`

    ```diff
    @@ -3,6 +3,8 @@
     import importlib.util
     import json
     import os
    +import sys
    +import types
     from pathlib import Path
     from unittest.mock import patch, MagicMock
     
    ```

    ```diff
    @@ -439,7 +441,11 @@ def test_default_returns_hermes(self):
             with patch.dict(os.environ, {}, clear=True):
                 os.environ.pop("HERMES_HONCHO_HOST", None)
                 os.environ.pop("HERMES_HOME", None)
    -            assert resolve_active_host() == "hermes"
    +            with patch(
    +                "plugins.memory.honcho.client.resolve_config_path",
    +                return_value=Path("/nonexistent/honcho.json"),
    +            ):
    +                assert resolve_active_host() == "hermes"
     
         def test_explicit_env_var_wins(self):
             with patch.dict(os.environ, {"HERMES_HONCHO_HOST": "hermes.coder"}):
    ```

    ```diff
    @@ -451,16 +457,52 @@ def test_profile_name_derives_host(self):
                 with patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"):
                     assert resolve_active_host() == "hermes_coder"
     
    +    def test_default_host_does_not_override_named_profile(self, tmp_path):
    +        """defaultHost is not applied before active-profile resolution."""
    +        config_file = tmp_path / "honcho.json"
    +        config_file.write_text(json.dumps({
    +            "defaultHost": "local",
    +            "hosts": {"local": {"workspace": "local-ws"}},
    +        }))
    +
    +        with patch.dict(os.environ, {}, clear=False):
    +            os.environ.pop("HERMES_HONCHO_HOST", None)
    +            with patch("hermes_cli.profiles.get_active_profile_name", return_value="coder"), \
    +                 patch("plugins.memory.honcho.client.resolve_config_path", return_value=config_file):
    +                assert resolve_active_host() == "hermes_coder"
    +
    +    def test_default_host_applies_to_default_profile_only(self, tmp_path):
    +        """default profile can use setup-generated defaultHost without leaking to other profiles."""
    +        config_file = tmp_path / "honcho.json"
    +        config_file.write_text(json.dumps({
    +            "defaultHost": "local",
    +            "hosts": {"local": {"workspace": "local-ws"}},
    +        }))
    +
    +        with patch.dict(os.environ, {}, clear=False):
    +            os.environ.pop("HERMES_HONCHO_HOST", None)
    +            with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), \
    +                 patch("plugins.memory.honcho.client.resolve_config_path", return_value=config_file):
    +                assert resolve_active_host() == "local"
    +
         def test_default_profile_returns_hermes(self):
             with patch.dict(os.environ, {}, clear=False):
                 os.environ.pop("HERMES_HONCHO_HOST", None)
    -            with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"):
    +            with patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), \
    +                 patch(
    +                     "plugins.memory.honcho.client.resolve_config_path",
    +                     return_value=Path("/nonexistent/honcho.json"),
    +                 ):
                     assert resolve_active_host() == "hermes"
     
         def test_custom_profile_returns_hermes(self):
             with patch.dict(os.environ, {}, clear=False):
                 os.environ.pop("HERMES_HONCHO_HOST", None)
    -            with patch("hermes_cli.profiles.get_active_profile_name", return_value="custom"):
    +            with patch("hermes_cli.profiles.get_active_profile_name", return_value="custom"), \
    +                 patch(
    +                     "plugins.memory.honcho.client.resolve_config_path",
    +                     return_value=Path("/nonexistent/honcho.json"),
    +                 ):
                     assert resolve_active_host() == "hermes"
     
         def test_profiles_import_failure_falls_back(self):
    ```

    ```diff
    @@ -693,6 +735,44 @@ def test_hermes_request_timeout_alias_used(self):
             mock_honcho.assert_called_once()
             assert mock_honcho.call_args.kwargs["timeout"] == 77.5
     
    +    @pytest.mark.skipif(
    +        not importlib.util.find_spec("honcho"),
    +        reason="honcho SDK not installed"
    +    )
    +    def test_timeout_change_triggers_client_rebuild(self):
    +        """Changing timeout config must rebuild the cached client."""
    +        fake_honcho_1 = MagicMock(name="Honcho_v1")
    +        fake_honcho_2 = MagicMock(name="Honcho_v2")
    +        cfg = HonchoClientConfig(
    +            api_key="[redacted:secret-assignment]",
    +            workspace_id="hermes",
    +            environment="production",
    +        )
    +
    +        with patch("honcho.Honcho", return_value=fake_honcho_1) as mock_h1, \
    +             patch("hermes_cli.config.load_config", return_value={"honcho": {"timeout": 30}}):
    +            client1 = get_honcho_client(cfg)
    +
    +        assert client1 is fake_honcho_1
    +        assert mock_h1.call_args.kwargs["timeout"] == 30.0
    +
    +        # Same config — should return cached client (no rebuild)
    +        with patch("honcho.Honcho", return_value=fake_honcho_2) as mock_h2, \
    +             patch("hermes_cli.config.load_config", return_value={"honcho": {"timeout": 30}}):
    +            client2 = get_honcho_client(cfg)
    +
    +        assert client2 is fake_honcho_1  # still cached
    +        mock_h2.assert_not_called()
    +
    +        # Changed timeout — must rebuild
    +        with patch("honcho.Honcho", return_value=fake_honcho_2) as mock_h3, \
    +             patch("hermes_cli.config.load_config", return_value={"honcho": {"timeout": 300}}):
    +            client3 = get_honcho_client(cfg)
    +
    +        assert client3 is fake_honcho_2  # rebuilt
    +        mock_h3.assert_called_once()
    +        assert mock_h3.call_args.kwargs["timeout"] == 300.0
    +
     
     class TestResolveSessionNameGatewayKey:
         """Regression tests for gateway_session_key priority in resolve_session_name.
    ```

    ```diff
    @@ -976,6 +1056,74 @@ def test_local_base_url_without_version_unchanged(self):
                 f"Expected 'http://localhost:38000', got {passed_base_url!r}"
             )
     
    +    def test_lan_default_host_empty_key_uses_local_placeholder(self, tmp_path):
    +        """Regression for #61661: setup-style root baseUrl + defaultHost + LAN IP
    +        must not pass an empty/None api_key to the SDK for a no-auth local server."""
    +        config_file = tmp_path / "honcho.json"
    +        config_file.write_text(json.dumps({
    +            "defaultHost": "local",
    +            "baseUrl": "http://192.168.2.112:8000",
    +            "hosts": {
    +                "local": {
    +                    "workspace": "local-ws",
    +                    "aiPeer": "local-ai",
    +                    "apiKey": "",
    +                },
    +            },
    +        }))
    +
    +        with patch.dict(os.environ, {}, clear=True), \
    +             patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), \
    +             patch("plugins.memory.honcho.client.resolve_config_path", return_value=config_file):
    +            cfg = HonchoClientConfig.from_global_config(config_path=config_file)
    +
    +        assert cfg.host == "local"
    +        assert cfg.workspace_id == "local-ws"
    +        assert cfg.ai_peer == "local-ai"
    +        assert cfg.api_key is None
    +        assert cfg.base_url == "http://192.168.2.112:8000"
    +
    +        fake_honcho = MagicMock(name="Honcho")
    +        mock_honcho = MagicMock(return_value=fake_honcho)
    +        fake_honcho_module = types.SimpleNamespace(Honcho=mock_honcho)
    +        with patch.dict(sys.modules, {"honcho": fake_honcho_module}), \
    +             patch("hermes_cli.config.load_config", return_value={}):
    +            get_honcho_client(cfg)
    +
    +        mock_honcho.assert_called_once()
    +        assert mock_honcho.call_args.kwargs["api_key"] == "local"
    +        assert mock_honcho.call_args.kwargs["base_url"] == "http://192.168.2.112:8000"
    +
    +    def test_lan_default_host_explicit_host_key_preserved(self, tmp_path):
    +        """A host-block local JWT still wins for LAN/VPN local URLs."""
    +        config_file = tmp_path / "honcho.json"
    +        config_file.write_text(json.dumps({
    +            "defaultHost": "local",
    +            "baseUrl": "http://192.168.2.112:8000",
    +            "hosts": {
    +                "local": {
    +                    "workspace": "local-ws",
    +                    "aiPeer": "local-ai",
    +                    "apiKey": "[redacted:secret-assignment]",
    +                },
    +            },
    +        }))
    +
    +        with patch.dict(os.environ, {}, clear=True), \
    +             patch("hermes_cli.profiles.get_active_profile_name", return_value="default"), \
    +             patch("plugins.memory.honcho.client.resolve_config_path", return_value=config_file):
    +            cfg = HonchoClientConfig.from_global_config(config_path=config_file)
    +
    +        fake_honcho = MagicMock(name="Honcho")
    +        mock_honcho = MagicMock(return_value=fake_honcho)
    +        fake_honcho_module = types.SimpleNamespace(Honcho=mock_honcho)
    +        with patch.dict(sys.modules, {"honcho": fake_honcho_module}), \
    +             patch("hermes_cli.config.load_config", return_value={}):
    +            get_honcho_client(cfg)
    +
    +        mock_honcho.assert_called_once()
    +        assert mock_honcho.call_args.kwargs["api_key"] == "local-jwt"
    +
         @pytest.mark.skipif(
             not importlib.util.find_spec("honcho"),
             reason="honcho SDK not installed"
    ```
  - `tests/honcho_plugin/test_session.py`

    ```diff
    @@ -361,6 +361,44 @@ def test_get_prefetch_context_fetches_user_and_ai_from_peer_api(self):
             user_peer.context.assert_called_once_with(target=session.user_peer_id)
             ai_peer.context.assert_called_once_with(target=session.assistant_peer_id)
     
    +    def test_get_prefetch_context_uses_assistant_observer_for_user_when_ai_observe_others(self):
    +        """With ai_observe_others enabled, get_prefetch_context must query
    +        the user context through the assistant observer, not the user peer."""
    +        mgr, session = self._make_cached_manager()
    +        mgr._ai_observe_others = True
    +
    +        assistant_peer = MagicMock()
    +
    +        def _assistant_context(**kwargs):
    +            if kwargs.get("target") == session.user_peer_id:
    +                return SimpleNamespace(
    +                    representation="User via assistant",
    +                    peer_card=["Name: Robert"],
    +                )
    +            if kwargs.get("target") == session.assistant_peer_id:
    +                return SimpleNamespace(
    +                    representation="AI self",
    +                    peer_card=["Role: Assistant"],
    +                )
    +            return SimpleNamespace(representation="Unknown", peer_card=[])
    +
    +        assistant_peer.context.side_effect = _assistant_context
    +        mgr._get_or_create_peer = MagicMock(
    +            side_effect=[assistant_peer, assistant_peer],
    +        )
    +
    +        result = mgr.get_prefetch_context(session.key)
    +
    +        assert result == {
    +            "representation": "User via assistant",
    +            "card": "Name: Robert",
    +            "ai_representation": "AI self",
    +            "ai_card": "Role: Assistant",
    +        }
    +        assert assistant_peer.context.call_count == 2
    +        assistant_peer.context.assert_any_call(target=session.user_peer_id)
    +        assistant_peer.context.assert_any_call(target=session.assistant_peer_id)
    +
         def test_get_ai_representation_uses_peer_api(self):
             mgr, session = self._make_cached_manager()
             ai_peer = MagicMock()
    ```

    ```diff
    @@ -566,6 +604,19 @@ def test_honcho_search_can_target_explicit_peer_id(self):
                 peer="hermes",
             )
     
    +    def test_honcho_search_rejects_whitespace_only_query(self):
    +        """Whitespace-only query must not hit Honcho search API."""
    +        import json
    +        provider = HonchoMemoryProvider()
    +        provider._session_initialized = True
    +        provider._session_key = "telegram:123"
    +        provider._manager = MagicMock()
    +
    +        result = provider.handle_tool_call("honcho_search", {"query": "  \t\n  "})
    +        parsed = json.loads(result)
    +        assert parsed == {"error": "Missing required parameter: query"}
    +        provider._manager.search_context.assert_not_called()
    +
         def test_honcho_reasoning_can_target_explicit_peer_id(self):
             provider = HonchoMemoryProvider()
             provider._session_initialized = True
    ```

    ```diff
    @@ -587,6 +638,19 @@ def test_honcho_reasoning_can_target_explicit_peer_id(self):
                 apply_injection_cap=False,
             )
     
    +    def test_honcho_reasoning_rejects_whitespace_only_query(self):
    +        """Whitespace-only query must not hit Honcho dialectic API."""
    +        import json
    +        provider = HonchoMemoryProvider()
    +        provider._session_initialized = True
    +        provider._session_key = "telegram:123"
    +        provider._manager = MagicMock()
    +
    +        result = provider.handle_tool_call("honcho_reasoning", {"query": "   "})
    +        parsed = json.loads(result)
    +        assert parsed == {"error": "Missing required parameter: query"}
    +        provider._manager.dialectic_query.assert_not_called()
    +
         def test_honcho_conclude_missing_both_params_returns_error(self):
             """Calling honcho_conclude with neither conclusion nor delete_id returns a tool error."""
             import json
    ```
- **#57437: fix(plugins): rebuild Honcho client when timeout config changes**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#66052`

## Fix status

- Merged to main in PR #66052 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-17`.
- Subsystem: `agent`
