NousResearch/hermes-agent

Honcho client rebuild on timeout config changes

Cluster 57347 · agent. Data as of

Nothing open on GitHub. Fix merged in #66052 on 17 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #66052. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
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.

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.

  • #66052 fix(honcho): salvage five community recall/config fixes onto current main

    merged to main

    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

    Show the diff
    • plugins/memory/honcho/__init__.py
      @@ -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"],
                   },
      @@ -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)
      @@ -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
      @@ -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
      @@ -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
      @@ -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:
      @@ -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:
      @@ -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:
      @@ -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:
      @@ -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()
      @@ -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"))
      @@ -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)
      @@ -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
      @@ -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
      @@ -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)
      +    "[email protected]": "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
      @@ -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
       
      @@ -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"}):
      @@ -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):
      @@ -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.
      @@ -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
      @@ -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()
      @@ -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
      @@ -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

1 PR closed without merging

From the close comments: merged via another change 1

  • #57437 fix(plugins): rebuild Honcho client when timeout config changes · merged via another change → #66052
Closed issues · 1
  • Closed#57347Honcho client is a per-process singleton — timeout config changes ignored until process restart
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: 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
#66052
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
17 Jul 2026

For your agent

The same facts, typed.