{"repo":"NousResearch/hermes-agent","public_id":"57347","display_label":"Honcho client rebuild on timeout config changes","summary":"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.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":66052,"merged_at":"2026-07-17T05:25:22+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-17T05:25:22+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":57347,"pr_number":66052,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-17T05:25:22+00:00"},{"issue_number":59470,"pr_number":66052,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-07-17T05:25:22+00:00"}],"members":[{"kind":"issue","number":57347,"title":"Honcho client is a per-process singleton — timeout config changes ignored until process restart","url":null,"state":"closed"},{"kind":"pr","number":57437,"title":"fix(plugins): rebuild Honcho client when timeout config changes","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#66052","number":66052}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":66052,"title":"fix(honcho): salvage five community recall/config fixes onto current main","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"plugins/memory/honcho/__init__.py","hunks":["@@ -125,13 +125,26 @@\n                 \"type\": \"string\",\n                 \"description\": (\n                     \"Override the default reasoning depth. \"\n-                    \"Omit to use the configured default (typically low). \"\n-                    \"Guide:\\n\"\n-                    \"- minimal: quick factual lookups (name, role, simple preference)\\n\"\n-                    \"- low: straightforward questions with clear answers\\n\"\n-                    \"- medium: multi-aspect questions requiring synthesis across observations\\n\"\n-                    \"- high: complex behavioral patterns, contradictions, deep analysis\\n\"\n-                    \"- max: thorough audit-level analysis, leave no stone unturned\"\n+                    \"Omit to use the configured default (typically low).\\n\"\n+                    \"reasoning_level parameter guide:\\n\"\n+                    \"- minimal: use ONLY for a single quick factual lookup (e.g. \"\n+                    \"'what is the user's name'). Honcho hard-caps this tier's output \"\n+                    \"at 250 tokens combined with the model's own hidden reasoning \"\n+                    \"tokens — a multi-part answer can get cut off mid-thought before \"\n+                    \"it even reaches the final-answer phase, especially on models \"\n+                    \"with reasoning/thinking enabled.\\n\"\n+                    \"- low/medium/high/max: use for anything requiring a synthesized, \"\n+                    \"multi-fact, or summary-style answer (e.g. 'summarize known facts \"\n+                    \"about this peer', 'what are their communication preferences'). \"\n+                    \"These tiers have no output-token cap of their own (fall back to \"\n+                    \"Honcho's 8192-token global default), so they don't have \"\n+                    \"minimal's cutoff failure mode.\\n\"\n+                    \"  - low: straightforward questions with clear answers\\n\"\n+                    \"  - medium: multi-aspect questions requiring synthesis across observations\\n\"\n+                    \"  - high: complex behavioral patterns, contradictions, deep analysis\\n\"\n+                    \"  - max: thorough audit-level analysis, leave no stone unturned\\n\"\n+                    \"Default to at least 'low' unless the query is genuinely a single \"\n+                    \"fact lookup.\"\n                 ),\n                 \"enum\": [\"minimal\", \"low\", \"medium\", \"high\", \"max\"],\n             },","@@ -1437,7 +1450,7 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:\n                 return json.dumps({\"result\": card})\n \n             elif tool_name == \"honcho_search\":\n-                query = args.get(\"query\", \"\")\n+                query = (args.get(\"query\") or \"\").strip()\n                 if not query:\n                     return tool_error(\"Missing required parameter: query\")\n                 max_tokens = [redacted:secret-assignment]\"max_tokens\", 800)), 2000)","@@ -1450,7 +1463,7 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:\n                 return json.dumps({\"result\": result})\n \n             elif tool_name == \"honcho_reasoning\":\n-                query = args.get(\"query\", \"\")\n+                query = (args.get(\"query\") or \"\").strip()\n                 if not query:\n                     return tool_error(\"Missing required parameter: query\")\n                 peer = args.get(\"peer\", \"user\")"]},{"file":"plugins/memory/honcho/cli.py","hunks":["@@ -1099,7 +1099,7 @@ def cmd_status(args) -> None:\n     print(f\"  Recall mode:    {hcfg.recall_mode}\")\n     print(f\"  Context budget: {hcfg.context_tokens or '(uncapped)'} tokens\")\n     raw = getattr(hcfg, \"raw\", None) or {}\n-    dialectic_cadence = raw.get(\"dialecticCadence\") or 1\n+    dialectic_cadence = getattr(hcfg, \"dialectic_cadence\", None) or raw.get(\"dialecticCadence\") or 1\n     print(f\"  Dialectic cad:  every {dialectic_cadence} turn{'s' if dialectic_cadence != 1 else ''}\")\n     reasoning_cap = raw.get(\"reasoningLevelCap\") or hcfg.reasoning_level_cap\n     heuristic_on = \"on\" if hcfg.reasoning_heuristic else \"off\""]},{"file":"plugins/memory/honcho/client.py","hunks":["@@ -17,8 +17,10 @@\n import os\n import logging\n import hashlib\n+import ipaddress\n from dataclasses import dataclass, field\n from pathlib import Path\n+from urllib.parse import urlparse\n \n from hermes_constants import get_hermes_home\n from hermes_cli.profiles import _get_default_hermes_home","@@ -56,8 +58,9 @@ def resolve_active_host() -> str:\n \n     Resolution order:\n       1. HERMES_HONCHO_HOST env var (explicit override)\n-      2. Active profile name via profiles system -> ``hermes.<profile>``\n-      3. Fallback: ``\"hermes\"`` (default profile)\n+      2. Active profile name via profiles system -> ``hermes_<profile>``\n+      3. defaultHost from the active config, but only for the default profile\n+      4. Fallback: ``\"hermes\"`` (default profile)\n     \"\"\"\n     explicit = os.environ.get(\"HERMES_HONCHO_HOST\", \"\").strip()\n     if explicit:","@@ -66,10 +69,26 @@ def resolve_active_host() -> str:\n     try:\n         from hermes_cli.profiles import get_active_profile_name\n         profile = get_active_profile_name()\n-        return profile_host_key(profile)\n+        profile_host = profile_host_key(profile)\n     except Exception:\n-        pass\n-    return HOST\n+        profile_host = HOST\n+\n+    # Honcho's generic config can carry a defaultHost (for example \"local\"),\n+    # but applying it before profile resolution makes every named Hermes\n+    # profile share that same host.  Keep named profiles isolated; only the\n+    # default Hermes profile may opt into the config's default host.\n+    if profile_host == HOST:\n+        try:\n+            path = resolve_config_path()\n+            if path.exists():\n+                raw = json.loads(path.read_text(encoding=\"utf-8\"))\n+                default_host = str(raw.get(\"defaultHost\", \"\")).strip()\n+                if default_host:\n+                    return default_host\n+        except Exception:\n+            pass\n+\n+    return profile_host\n \n \n def resolve_global_config_path() -> Path:","@@ -226,6 +245,44 @@ def _parse_dialectic_depth_levels(host_val, root_val, depth: int) -> list[str] |\n _DEFAULT_HTTP_TIMEOUT = 30.0\n \n \n+def _is_local_base_url(base_url: str | None) -> bool:\n+    \"\"\"Return True for loopback/LAN/VPN self-hosted Honcho URLs.\n+\n+    Local Honcho deployments can run without auth, but the SDK requires a\n+    non-empty api_key argument.  Treat loopback plus RFC1918/link-local/ULA\n+    and carrier-grade-NAT IPs as local so LAN/VPN URLs such as\n+    ``http://192.168.2.112:8000`` get the same placeholder-key behavior as\n+    localhost.\n+    \"\"\"\n+    if not base_url:\n+        return False\n+\n+    try:\n+        parsed = urlparse(base_url)\n+        host = (parsed.hostname or \"\").strip().lower()\n+    except Exception:\n+        host = \"\"\n+\n+    if host in {\"localhost\", \"127.0.0.1\", \"::1\"}:\n+        return True\n+    if not host:\n+        return False\n+\n+    try:\n+        ip = ipaddress.ip_address(host)\n+    except ValueError:\n+        return False\n+\n+    if ip.is_loopback or ip.is_private or ip.is_link_local:\n+        return True\n+\n+    # Tailscale/other VPN setups often sit in carrier-grade NAT space.\n+    if ip.version == 4 and ipaddress.ip_address(\"100.64.0.0\") <= ip <= ipaddress.ip_address(\"100.127.255.255\"):\n+        return True\n+\n+    return False\n+\n+\n def _resolve_optional_float(*values: Any) -> float | None:\n     \"\"\"Return the first non-empty value coerced to a positive float.\"\"\"\n     for value in values:","@@ -798,6 +855,50 @@ def resolve_session_name(\n \n \n _honcho_client_slot: SingletonSlot = SingletonSlot()\n+_cached_timeout: float | None = None\n+# Memo for the config.yaml-derived timeout, keyed on the file's mtime_ns so\n+# the staleness check on every get_honcho_client() call costs one stat()\n+# instead of a full YAML load. (None, None) = not yet populated.\n+_config_timeout_memo: tuple[int | None, float | None] = (None, None)\n+\n+\n+def _config_yaml_timeout() -> float | None:\n+    \"\"\"Read honcho.timeout / honcho.request_timeout from config.yaml, memoized on mtime.\"\"\"\n+    global _config_timeout_memo\n+    try:\n+        from hermes_constants import get_hermes_home\n+\n+        cfg_path = get_hermes_home() / \"config.yaml\"\n+        try:\n+            mtime_ns: int | None = cfg_path.stat().st_mtime_ns\n+        except OSError:\n+            mtime_ns = None\n+        if _config_timeout_memo[0] is not None and _config_timeout_memo[0] == mtime_ns:\n+            return _config_timeout_memo[1]\n+\n+        from hermes_cli.config import load_config\n+\n+        honcho_cfg = load_config().get(\"honcho\", {})\n+        timeout = None\n+        if isinstance(honcho_cfg, dict):\n+            timeout = _resolve_optional_float(\n+                honcho_cfg.get(\"timeout\"),\n+                honcho_cfg.get(\"request_timeout\"),\n+            )\n+        _config_timeout_memo = (mtime_ns, timeout)\n+        return timeout\n+    except Exception:\n+        return None\n+\n+\n+def _resolve_timeout_from_sources(config: HonchoClientConfig | None) -> float:\n+    \"\"\"Resolve the effective timeout from env, config.yaml, and the explicit config.\"\"\"\n+    timeout = config.timeout if config is not None else None\n+    if timeout is None:\n+        timeout = _resolve_optional_float(os.environ.get(\"HONCHO_TIMEOUT\"))\n+    if timeout is None:\n+        timeout = _config_yaml_timeout()\n+    return timeout if timeout is not None else _DEFAULT_HTTP_TIMEOUT\n \n \n def _apply_fresh_oauth_token(config: HonchoClientConfig) -> None:","@@ -843,10 +944,20 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:\n     first calls (double-checked locking via ``SingletonSlot``), so racing\n     threads can't each construct a client and leak the loser's connection.\n     \"\"\"\n+    global _cached_timeout\n     cached = _honcho_client_slot.peek()\n     if cached is not None:\n-        _refresh_cached_oauth(cached, config)\n-        return cached\n+        # Detect timeout config changes in long-lived processes (gateway,\n+        # dashboard).  If the user changed the timeout after the client was\n+        # built, rebuild with the new value.\n+        new_timeout = _resolve_timeout_from_sources(config)\n+        if new_timeout != _cached_timeout:\n+            _honcho_client_slot.reset()\n+            _cached_timeout = None\n+            cached = None\n+        else:\n+            _refresh_cached_oauth(cached, config)\n+            return cached\n \n     if config is None:\n         config = HonchoClientConfig.from_global_config()","@@ -922,16 +1033,12 @@ def _build() -> \"Honcho\":\n         # For local: only use config.api_key if the host block explicitly\n         # sets apiKey (meaning the user wants local auth). Otherwise skip\n         # the stored key -- it's likely a cloud key that would break local.\n-        _is_local = resolved_base_url and (\n-            \"localhost\" in resolved_base_url\n-            or \"127.0.0.1\" in resolved_base_url\n-            or \"::1\" in resolved_base_url\n-        )\n+        _is_local = _is_local_base_url(resolved_base_url)\n         if _is_local:\n             # Check if the host block has its own apiKey (explicit local auth).\n-            # Auth-skipping is loopback-only: a stored key is likely a cloud key\n-            # that would break a no-auth local server, so we substitute the SDK's\n-            # required-non-empty placeholder unless the host block opts in.\n+            # For local/LAN/VPN self-hosts, a stored root key is likely a cloud\n+            # key that would break a no-auth local server, so we substitute the\n+            # SDK's required-non-empty placeholder unless the host block opts in.\n             _raw = config.raw or {}\n             _host_block = (_raw.get(\"hosts\") or {}).get(config.host, {})\n             _host_has_key = bool(_host_block.get(\"apiKey\"))","@@ -961,6 +1068,8 @@ def _build() -> \"Honcho\":\n         if resolved_timeout is not None:\n             kwargs[\"timeout\"] = resolved_timeout\n \n+        global _cached_timeout\n+        _cached_timeout = resolved_timeout\n         return Honcho(**kwargs)\n \n     return _honcho_client_slot.get(_build)","@@ -968,4 +1077,7 @@ def _build() -> \"Honcho\":\n \n def reset_honcho_client() -> None:\n     \"\"\"Reset the Honcho client singleton (useful for testing).\"\"\"\n+    global _cached_timeout, _config_timeout_memo\n     _honcho_client_slot.reset()\n+    _cached_timeout = None\n+    _config_timeout_memo = (None, None)"]},{"file":"plugins/memory/honcho/session.py","hunks":["@@ -742,7 +742,8 @@ def get_prefetch_context(self, session_key: str, user_message: str | None = None\n             logger.debug(\"Failed to fetch session summary from Honcho: %s\", e)\n \n         try:\n-            user_ctx = self._fetch_peer_context(session.user_peer_id, search_query=user_message or None, target=session.user_peer_id)\n+            observer_peer_id, target_peer_id = self._resolve_observer_target(session, \"user\")\n+            user_ctx = self._fetch_peer_context(observer_peer_id, search_query=user_message or None, target=target_peer_id or session.user_peer_id)\n             result[\"representation\"] = user_ctx[\"representation\"]\n             result[\"card\"] = \"\\n\".join(user_ctx[\"card\"])\n         except Exception as e:"]},{"file":"scripts/release.py","hunks":["@@ -53,6 +53,9 @@\n     \"[redacted:email]\": \"rlaehddus302\",  # PR #61985 salvage (gateway: secondary-adapter auth callback profile)\n     \"[redacted:email]\": \"aguung\",  # PR #64461 salvage (gateway: multiplex secret_scope for authz/Slack/webhooks)\n     \"[redacted:email]\": \"jtstothard\",  # PR #63256 salvage (gateway: multiplex secondary adapter config validation)\n+    \"[redacted:email]\": \"fjlaowan1983\",  # PR #11256 salvage (honcho: reject whitespace-only reasoning queries)\n+    \"RainbowAndSun@users.noreply.github.com\": \"RainbowAndSun\",  # PR #62982 salvage (honcho: observer target in prefetch context)\n+    \"[redacted:email]\": \"Elektrofussel\",  # PR #61675 salvage (honcho: defaultHost + private-range local URL detection)\n     \"[redacted:email]\": \"SAMBAS123\",  # PR #64986 salvage (gateway: multiplex primary bot token scope)\n     \"[redacted:email]\": \"kocaemre\",  # PR #36051 salvage (docs: audit round 3 code/doc reconciliation)\n     \"[redacted:email]\": \"m1qaweb\",  # PR #29290 salvage (gateway: strip /queue prefix when idle)"]},{"file":"tests/honcho_plugin/test_client.py","hunks":["@@ -3,6 +3,8 @@\n import importlib.util\n import json\n import os\n+import sys\n+import types\n from pathlib import Path\n from unittest.mock import patch, MagicMock\n ","@@ -439,7 +441,11 @@ def test_default_returns_hermes(self):\n         with patch.dict(os.environ, {}, clear=True):\n             os.environ.pop(\"HERMES_HONCHO_HOST\", None)\n             os.environ.pop(\"HERMES_HOME\", None)\n-            assert resolve_active_host() == \"hermes\"\n+            with patch(\n+                \"plugins.memory.honcho.client.resolve_config_path\",\n+                return_value=Path(\"/nonexistent/honcho.json\"),\n+            ):\n+                assert resolve_active_host() == \"hermes\"\n \n     def test_explicit_env_var_wins(self):\n         with patch.dict(os.environ, {\"HERMES_HONCHO_HOST\": \"hermes.coder\"}):","@@ -451,16 +457,52 @@ def test_profile_name_derives_host(self):\n             with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"coder\"):\n                 assert resolve_active_host() == \"hermes_coder\"\n \n+    def test_default_host_does_not_override_named_profile(self, tmp_path):\n+        \"\"\"defaultHost is not applied before active-profile resolution.\"\"\"\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"defaultHost\": \"local\",\n+            \"hosts\": {\"local\": {\"workspace\": \"local-ws\"}},\n+        }))\n+\n+        with patch.dict(os.environ, {}, clear=False):\n+            os.environ.pop(\"HERMES_HONCHO_HOST\", None)\n+            with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"coder\"), \\\n+                 patch(\"plugins.memory.honcho.client.resolve_config_path\", return_value=config_file):\n+                assert resolve_active_host() == \"hermes_coder\"\n+\n+    def test_default_host_applies_to_default_profile_only(self, tmp_path):\n+        \"\"\"default profile can use setup-generated defaultHost without leaking to other profiles.\"\"\"\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"defaultHost\": \"local\",\n+            \"hosts\": {\"local\": {\"workspace\": \"local-ws\"}},\n+        }))\n+\n+        with patch.dict(os.environ, {}, clear=False):\n+            os.environ.pop(\"HERMES_HONCHO_HOST\", None)\n+            with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"default\"), \\\n+                 patch(\"plugins.memory.honcho.client.resolve_config_path\", return_value=config_file):\n+                assert resolve_active_host() == \"local\"\n+\n     def test_default_profile_returns_hermes(self):\n         with patch.dict(os.environ, {}, clear=False):\n             os.environ.pop(\"HERMES_HONCHO_HOST\", None)\n-            with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"default\"):\n+            with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"default\"), \\\n+                 patch(\n+                     \"plugins.memory.honcho.client.resolve_config_path\",\n+                     return_value=Path(\"/nonexistent/honcho.json\"),\n+                 ):\n                 assert resolve_active_host() == \"hermes\"\n \n     def test_custom_profile_returns_hermes(self):\n         with patch.dict(os.environ, {}, clear=False):\n             os.environ.pop(\"HERMES_HONCHO_HOST\", None)\n-            with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"custom\"):\n+            with patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"custom\"), \\\n+                 patch(\n+                     \"plugins.memory.honcho.client.resolve_config_path\",\n+                     return_value=Path(\"/nonexistent/honcho.json\"),\n+                 ):\n                 assert resolve_active_host() == \"hermes\"\n \n     def test_profiles_import_failure_falls_back(self):","@@ -693,6 +735,44 @@ def test_hermes_request_timeout_alias_used(self):\n         mock_honcho.assert_called_once()\n         assert mock_honcho.call_args.kwargs[\"timeout\"] == 77.5\n \n+    @pytest.mark.skipif(\n+        not importlib.util.find_spec(\"honcho\"),\n+        reason=\"honcho SDK not installed\"\n+    )\n+    def test_timeout_change_triggers_client_rebuild(self):\n+        \"\"\"Changing timeout config must rebuild the cached client.\"\"\"\n+        fake_honcho_1 = MagicMock(name=\"Honcho_v1\")\n+        fake_honcho_2 = MagicMock(name=\"Honcho_v2\")\n+        cfg = HonchoClientConfig(\n+            api_key=\"[redacted:secret-assignment]\",\n+            workspace_id=\"hermes\",\n+            environment=\"production\",\n+        )\n+\n+        with patch(\"honcho.Honcho\", return_value=fake_honcho_1) as mock_h1, \\\n+             patch(\"hermes_cli.config.load_config\", return_value={\"honcho\": {\"timeout\": 30}}):\n+            client1 = get_honcho_client(cfg)\n+\n+        assert client1 is fake_honcho_1\n+        assert mock_h1.call_args.kwargs[\"timeout\"] == 30.0\n+\n+        # Same config — should return cached client (no rebuild)\n+        with patch(\"honcho.Honcho\", return_value=fake_honcho_2) as mock_h2, \\\n+             patch(\"hermes_cli.config.load_config\", return_value={\"honcho\": {\"timeout\": 30}}):\n+            client2 = get_honcho_client(cfg)\n+\n+        assert client2 is fake_honcho_1  # still cached\n+        mock_h2.assert_not_called()\n+\n+        # Changed timeout — must rebuild\n+        with patch(\"honcho.Honcho\", return_value=fake_honcho_2) as mock_h3, \\\n+             patch(\"hermes_cli.config.load_config\", return_value={\"honcho\": {\"timeout\": 300}}):\n+            client3 = get_honcho_client(cfg)\n+\n+        assert client3 is fake_honcho_2  # rebuilt\n+        mock_h3.assert_called_once()\n+        assert mock_h3.call_args.kwargs[\"timeout\"] == 300.0\n+\n \n class TestResolveSessionNameGatewayKey:\n     \"\"\"Regression tests for gateway_session_key priority in resolve_session_name.","@@ -976,6 +1056,74 @@ def test_local_base_url_without_version_unchanged(self):\n             f\"Expected 'http://localhost:38000', got {passed_base_url!r}\"\n         )\n \n+    def test_lan_default_host_empty_key_uses_local_placeholder(self, tmp_path):\n+        \"\"\"Regression for #61661: setup-style root baseUrl + defaultHost + LAN IP\n+        must not pass an empty/None api_key to the SDK for a no-auth local server.\"\"\"\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"defaultHost\": \"local\",\n+            \"baseUrl\": \"http://192.168.2.112:8000\",\n+            \"hosts\": {\n+                \"local\": {\n+                    \"workspace\": \"local-ws\",\n+                    \"aiPeer\": \"local-ai\",\n+                    \"apiKey\": \"\",\n+                },\n+            },\n+        }))\n+\n+        with patch.dict(os.environ, {}, clear=True), \\\n+             patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"default\"), \\\n+             patch(\"plugins.memory.honcho.client.resolve_config_path\", return_value=config_file):\n+            cfg = HonchoClientConfig.from_global_config(config_path=config_file)\n+\n+        assert cfg.host == \"local\"\n+        assert cfg.workspace_id == \"local-ws\"\n+        assert cfg.ai_peer == \"local-ai\"\n+        assert cfg.api_key is None\n+        assert cfg.base_url == \"http://192.168.2.112:8000\"\n+\n+        fake_honcho = MagicMock(name=\"Honcho\")\n+        mock_honcho = MagicMock(return_value=fake_honcho)\n+        fake_honcho_module = types.SimpleNamespace(Honcho=mock_honcho)\n+        with patch.dict(sys.modules, {\"honcho\": fake_honcho_module}), \\\n+             patch(\"hermes_cli.config.load_config\", return_value={}):\n+            get_honcho_client(cfg)\n+\n+        mock_honcho.assert_called_once()\n+        assert mock_honcho.call_args.kwargs[\"api_key\"] == \"local\"\n+        assert mock_honcho.call_args.kwargs[\"base_url\"] == \"http://192.168.2.112:8000\"\n+\n+    def test_lan_default_host_explicit_host_key_preserved(self, tmp_path):\n+        \"\"\"A host-block local JWT still wins for LAN/VPN local URLs.\"\"\"\n+        config_file = tmp_path / \"honcho.json\"\n+        config_file.write_text(json.dumps({\n+            \"defaultHost\": \"local\",\n+            \"baseUrl\": \"http://192.168.2.112:8000\",\n+            \"hosts\": {\n+                \"local\": {\n+                    \"workspace\": \"local-ws\",\n+                    \"aiPeer\": \"local-ai\",\n+                    \"apiKey\": \"[redacted:secret-assignment]\",\n+                },\n+            },\n+        }))\n+\n+        with patch.dict(os.environ, {}, clear=True), \\\n+             patch(\"hermes_cli.profiles.get_active_profile_name\", return_value=\"default\"), \\\n+             patch(\"plugins.memory.honcho.client.resolve_config_path\", return_value=config_file):\n+            cfg = HonchoClientConfig.from_global_config(config_path=config_file)\n+\n+        fake_honcho = MagicMock(name=\"Honcho\")\n+        mock_honcho = MagicMock(return_value=fake_honcho)\n+        fake_honcho_module = types.SimpleNamespace(Honcho=mock_honcho)\n+        with patch.dict(sys.modules, {\"honcho\": fake_honcho_module}), \\\n+             patch(\"hermes_cli.config.load_config\", return_value={}):\n+            get_honcho_client(cfg)\n+\n+        mock_honcho.assert_called_once()\n+        assert mock_honcho.call_args.kwargs[\"api_key\"] == \"local-jwt\"\n+\n     @pytest.mark.skipif(\n         not importlib.util.find_spec(\"honcho\"),\n         reason=\"honcho SDK not installed\""]},{"file":"tests/honcho_plugin/test_session.py","hunks":["@@ -361,6 +361,44 @@ def test_get_prefetch_context_fetches_user_and_ai_from_peer_api(self):\n         user_peer.context.assert_called_once_with(target=session.user_peer_id)\n         ai_peer.context.assert_called_once_with(target=session.assistant_peer_id)\n \n+    def test_get_prefetch_context_uses_assistant_observer_for_user_when_ai_observe_others(self):\n+        \"\"\"With ai_observe_others enabled, get_prefetch_context must query\n+        the user context through the assistant observer, not the user peer.\"\"\"\n+        mgr, session = self._make_cached_manager()\n+        mgr._ai_observe_others = True\n+\n+        assistant_peer = MagicMock()\n+\n+        def _assistant_context(**kwargs):\n+            if kwargs.get(\"target\") == session.user_peer_id:\n+                return SimpleNamespace(\n+                    representation=\"User via assistant\",\n+                    peer_card=[\"Name: Robert\"],\n+                )\n+            if kwargs.get(\"target\") == session.assistant_peer_id:\n+                return SimpleNamespace(\n+                    representation=\"AI self\",\n+                    peer_card=[\"Role: Assistant\"],\n+                )\n+            return SimpleNamespace(representation=\"Unknown\", peer_card=[])\n+\n+        assistant_peer.context.side_effect = _assistant_context\n+        mgr._get_or_create_peer = MagicMock(\n+            side_effect=[assistant_peer, assistant_peer],\n+        )\n+\n+        result = mgr.get_prefetch_context(session.key)\n+\n+        assert result == {\n+            \"representation\": \"User via assistant\",\n+            \"card\": \"Name: Robert\",\n+            \"ai_representation\": \"AI self\",\n+            \"ai_card\": \"Role: Assistant\",\n+        }\n+        assert assistant_peer.context.call_count == 2\n+        assistant_peer.context.assert_any_call(target=session.user_peer_id)\n+        assistant_peer.context.assert_any_call(target=session.assistant_peer_id)\n+\n     def test_get_ai_representation_uses_peer_api(self):\n         mgr, session = self._make_cached_manager()\n         ai_peer = MagicMock()","@@ -566,6 +604,19 @@ def test_honcho_search_can_target_explicit_peer_id(self):\n             peer=\"hermes\",\n         )\n \n+    def test_honcho_search_rejects_whitespace_only_query(self):\n+        \"\"\"Whitespace-only query must not hit Honcho search API.\"\"\"\n+        import json\n+        provider = HonchoMemoryProvider()\n+        provider._session_initialized = True\n+        provider._session_key = \"telegram:123\"\n+        provider._manager = MagicMock()\n+\n+        result = provider.handle_tool_call(\"honcho_search\", {\"query\": \"  \\t\\n  \"})\n+        parsed = json.loads(result)\n+        assert parsed == {\"error\": \"Missing required parameter: query\"}\n+        provider._manager.search_context.assert_not_called()\n+\n     def test_honcho_reasoning_can_target_explicit_peer_id(self):\n         provider = HonchoMemoryProvider()\n         provider._session_initialized = True","@@ -587,6 +638,19 @@ def test_honcho_reasoning_can_target_explicit_peer_id(self):\n             apply_injection_cap=False,\n         )\n \n+    def test_honcho_reasoning_rejects_whitespace_only_query(self):\n+        \"\"\"Whitespace-only query must not hit Honcho dialectic API.\"\"\"\n+        import json\n+        provider = HonchoMemoryProvider()\n+        provider._session_initialized = True\n+        provider._session_key = \"telegram:123\"\n+        provider._manager = MagicMock()\n+\n+        result = provider.handle_tool_call(\"honcho_reasoning\", {\"query\": \"   \"})\n+        parsed = json.loads(result)\n+        assert parsed == {\"error\": \"Missing required parameter: query\"}\n+        provider._manager.dialectic_query.assert_not_called()\n+\n     def test_honcho_conclude_missing_both_params_returns_error(self):\n         \"\"\"Calling honcho_conclude with neither conclusion nor delete_id returns a tool error.\"\"\"\n         import json\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}