From e7788401f5cc5a4fa589ae8f687b8fa0a74c1f45 Mon Sep 17 00:00:00 2001 From: Zayed Charef Date: Sun, 26 Jul 2026 17:55:54 +0200 Subject: [PATCH] feat(honcho): expose representation retrieval knobs Honcho splits its conclusion budget three ways in crud/representation.py: semantic = search_top_k or max_conclusions // 3 # driven by the query frequent = max_conclusions // 3 # ignores the query recent = max_conclusions - semantic - frequent # ignores the query This plugin never sends search_top_k, so with server defaults a peer context call returns 8 semantic + 8 frequent + 9 recent out of 25: 17 of 25 conclusions that no query influenced, drawn by ORDER BY created_at DESC. Because documents.created_at holds the derivation time, a bulk import derives inside one window and that recent slice resolves to the same rows for every query. The SDK already accepts search_top_k, search_max_distance, max_conclusions and include_most_frequent on both peer.context() and peer.representation(), and the server exposes them. Only this plugin dropped them. The naming matches the Claude Code plugin, which already ships searchTopK / searchMaxDistance / maxConclusions. Setting searchTopK >= maxConclusions drives the frequent and recent slices to zero, so every injected conclusion is one the query selected. All four fields default to None and are omitted from the call when unset, so an existing deployment behaves exactly as before. Covered by tests/test_honcho_retrieval_knobs.py. Also fixes the peer.representation() fallback silently dropping search_query, which made that path return a fully unfiltered representation whenever peer.context() raised. --- plugins/memory/honcho/cli.py | 16 ++- plugins/memory/honcho/client.py | 71 ++++++++++++++ plugins/memory/honcho/config_schema.py | 47 +++++++++ plugins/memory/honcho/session.py | 39 +++++++- tests/test_honcho_retrieval_knobs.py | 129 +++++++++++++++++++++++++ 5 files changed, 298 insertions(+), 4 deletions(-) create mode 100644 tests/test_honcho_retrieval_knobs.py diff --git a/plugins/memory/honcho/cli.py b/plugins/memory/honcho/cli.py index 7c2b87a8ba1c..c95242415e71 100644 --- a/plugins/memory/honcho/cli.py +++ b/plugins/memory/honcho/cli.py @@ -48,6 +48,8 @@ def clone_honcho_for_profile(profile_name: str) -> bool: "sessionPeerPrefix", "contextTokens", "dialecticReasoningLevel", "dialecticDynamic", "dialecticMaxChars", "messageMaxChars", "dialecticMaxInputChars", "saveMessages", "observation", + "searchTopK", "searchMaxDistance", "maxConclusions", + "includeMostFrequent", "pinUserPeer", "userPeerAliases", "runtimePeerPrefix"): val = default_block.get(key) if val is not None: @@ -117,7 +119,8 @@ def cmd_enable(args) -> None: for key in ("recallMode", "writeFrequency", "sessionStrategy", "contextTokens", "dialecticReasoningLevel", "dialecticDynamic", "dialecticMaxChars", "messageMaxChars", "dialecticMaxInputChars", - "saveMessages", "observation"): + "saveMessages", "observation", "searchTopK", "searchMaxDistance", + "maxConclusions", "includeMostFrequent"): val = default_block.get(key) if val is not None and key not in block: block[key] = val @@ -1203,6 +1206,17 @@ def cmd_status(args) -> None: heuristic_on = "on" if hcfg.reasoning_heuristic else "off" print(f" Reasoning: base={hcfg.dialectic_reasoning_level}, cap={reasoning_cap}, heuristic={heuristic_on}") print(f" Observation: user(me={hcfg.user_observe_me},others={hcfg.user_observe_others}) ai(me={hcfg.ai_observe_me},others={hcfg.ai_observe_others})") + _gated = ( + hcfg.search_top_k is not None + and hcfg.max_conclusions is not None + and hcfg.search_top_k >= hcfg.max_conclusions + ) + print( + f" Retrieval: topK={hcfg.search_top_k or '(default)'} " + f"maxConclusions={hcfg.max_conclusions or '(default)'} " + f"maxDistance={hcfg.search_max_distance if hcfg.search_max_distance is not None else '(default)'}" + + (" [all query-gated]" if _gated else " [includes query-independent conclusions]") + ) print(f" Write freq: {hcfg.write_frequency}") if hcfg.enabled and (hcfg.api_key or hcfg.base_url): diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index cc1da55ffaf4..c95c20866816 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -153,6 +153,46 @@ def _parse_context_tokens(host_val, root_val) -> int | None: return None +def _parse_optional_int(host_val, root_val) -> int | None: + """Parse an optional integer config: host wins, then root, then None. + + None means "do not send this parameter", preserving Honcho's server-side + default. Used for the representation retrieval knobs, where omitting the + field must behave exactly as before it existed. + """ + for val in (host_val, root_val): + if val is not None: + try: + return int(val) + except (ValueError, TypeError): + pass + return None + + +def _parse_optional_float(host_val, root_val) -> float | None: + """Parse an optional float config: host wins, then root, then None.""" + for val in (host_val, root_val): + if val is not None: + try: + return float(val) + except (ValueError, TypeError): + pass + return None + + +def _parse_optional_bool(host_val, root_val) -> bool | None: + """Parse an optional bool config: host wins, then root, then None. + + Distinct from ``_resolve_bool``, which collapses an unset field onto a + default. Here the tri-state is load-bearing: None must stay None so the + parameter is left out of the API call entirely. + """ + for val in (host_val, root_val): + if val is not None: + return bool(val) + return None + + def _parse_int_config(host_val, root_val, default: int) -> int: """Parse an integer config: host wins, then root, then default.""" for val in (host_val, root_val): @@ -392,6 +432,21 @@ class HonchoClientConfig: write_frequency: str | int = "async" # Prefetch budget (None = no cap; set to an integer to bound auto-injected context) context_tokens: int | None = None + # Representation retrieval knobs. All None by default: omitted from the call, + # so Honcho's server-side defaults apply and behaviour is unchanged. + # + # Honcho splits the conclusion budget three ways (crud/representation.py): + # semantic = search_top_k or max_conclusions // 3 # driven by the query + # frequent = max_conclusions // 3 # ignores the query + # recent = max_conclusions - semantic - frequent # ignores the query + # With the defaults that is 8 + 8 + 9 of 25, i.e. 17 of 25 conclusions that no + # query influenced, drawn by `ORDER BY created_at DESC`. Setting + # search_top_k >= max_conclusions drives `frequent` and `recent` to zero, so + # every injected conclusion is one the query actually selected. + search_top_k: int | None = None + search_max_distance: float | None = None + max_conclusions: int | None = None + include_most_frequent: bool | None = None # Dialectic (peer.chat) settings # reasoning_level: "minimal" | "low" | "medium" | "high" | "max" dialectic_reasoning_level: str = "low" @@ -625,6 +680,22 @@ def from_global_config( host_block.get("contextTokens"), raw.get("contextTokens"), ), + search_top_k=_parse_optional_int( + host_block.get("searchTopK"), + raw.get("searchTopK"), + ), + search_max_distance=_parse_optional_float( + host_block.get("searchMaxDistance"), + raw.get("searchMaxDistance"), + ), + max_conclusions=_parse_optional_int( + host_block.get("maxConclusions"), + raw.get("maxConclusions"), + ), + include_most_frequent=_parse_optional_bool( + host_block.get("includeMostFrequent"), + raw.get("includeMostFrequent"), + ), dialectic_reasoning_level=( host_block.get("dialecticReasoningLevel") or raw.get("dialecticReasoningLevel") diff --git a/plugins/memory/honcho/config_schema.py b/plugins/memory/honcho/config_schema.py index b5298e20be9e..bee8ba006489 100644 --- a/plugins/memory/honcho/config_schema.py +++ b/plugins/memory/honcho/config_schema.py @@ -298,6 +298,53 @@ description="Initialize the session eagerly in tools mode instead of on first tool call.", group="Recall", ), + # — Retrieval — + # Honcho splits the conclusion budget three ways: semantic (query-driven), + # most-frequent, and recent (`ORDER BY created_at DESC`). Only the first + # depends on the query, so the defaults inject a majority of conclusions + # the conversation never asked for. Setting searchTopK >= maxConclusions + # drives the other two slices to zero. + ProviderField( + key="searchTopK", + label="Search top K", + kind=KIND_NUMBER, + description=( + "Number of semantically relevant conclusions to retrieve. Set it to at " + "least Max conclusions so no query-independent conclusions are injected." + ), + placeholder="(server default: a third of the budget)", + group="Retrieval", + ), + ProviderField( + key="maxConclusions", + label="Max conclusions", + kind=KIND_NUMBER, + description="Ceiling on conclusions included in the representation.", + placeholder="(server default: 25)", + group="Retrieval", + ), + ProviderField( + key="searchMaxDistance", + label="Search max distance", + kind=KIND_NUMBER, + description=( + "Semantic distance cutoff, 0.0-1.0. The only relevance threshold in the " + "retrieval path. Lower is stricter; around 0.3 is 'very close'." + ), + placeholder="(server default)", + group="Retrieval", + ), + ProviderField( + key="includeMostFrequent", + label="Include most frequent", + kind=KIND_BOOL, + description=( + "Include the most-frequent conclusions slice. Disabling it does NOT " + "reduce noise on its own: that budget moves to the recent slice, which " + "ignores the query too. Use searchTopK for that." + ), + group="Retrieval", + ), # — Limits — ProviderField( key="messageMaxChars", diff --git a/plugins/memory/honcho/session.py b/plugins/memory/honcho/session.py index 97bdb3b35eed..f35e5e68e0fd 100644 --- a/plugins/memory/honcho/session.py +++ b/plugins/memory/honcho/session.py @@ -133,6 +133,18 @@ def __init__( self._user_observe_others: bool = config.user_observe_others if config else True self._ai_observe_me: bool = config.ai_observe_me if config else True self._ai_observe_others: bool = config.ai_observe_others if config else True + # Representation retrieval knobs. None = omit the parameter, keeping + # Honcho's server-side default. See HonchoClientConfig for the budget + # arithmetic that makes search_top_k >= max_conclusions the setting + # which eliminates query-independent conclusions. + self._search_top_k: int | None = config.search_top_k if config else None + self._search_max_distance: float | None = ( + config.search_max_distance if config else None + ) + self._max_conclusions: int | None = config.max_conclusions if config else None + self._include_most_frequent: bool | None = ( + config.include_most_frequent if config else None + ) self._message_max_chars: int = ( config.message_max_chars if config else 25000 ) @@ -957,6 +969,23 @@ def _fetch_peer_card(self, peer_id: str, *, target: str | None = None) -> list[s return [] + def _retrieval_kwargs(self) -> dict[str, Any]: + """Retrieval knobs to forward to peer.context() / peer.representation(). + + Only non-None values are included, so an unconfigured deployment sends + nothing extra and keeps Honcho's server-side defaults. + """ + kwargs: dict[str, Any] = {} + if self._search_top_k is not None: + kwargs["search_top_k"] = self._search_top_k + if self._search_max_distance is not None: + kwargs["search_max_distance"] = self._search_max_distance + if self._max_conclusions is not None: + kwargs["max_conclusions"] = self._max_conclusions + if self._include_most_frequent is not None: + kwargs["include_most_frequent"] = self._include_most_frequent + return kwargs + def _fetch_peer_context( self, peer_id: str, @@ -975,6 +1004,7 @@ def _fetch_peer_context( context_kwargs["target"] = target if search_query is not None: context_kwargs["search_query"] = search_query + context_kwargs.update(self._retrieval_kwargs()) ctx = peer.context(**context_kwargs) if context_kwargs else peer.context() representation = ( getattr(ctx, "representation", None) @@ -987,9 +1017,12 @@ def _fetch_peer_context( if not representation: try: - representation = ( - peer.representation(target=target) if target is not None else peer.representation() - ) or "" + repr_kwargs: dict[str, Any] = dict(self._retrieval_kwargs()) + if target is not None: + repr_kwargs["target"] = target + if search_query is not None: + repr_kwargs["search_query"] = search_query + representation = peer.representation(**repr_kwargs) or "" except Exception as e: logger.debug("Direct peer.representation() failed for '%s': %s", peer_id, e) diff --git a/tests/test_honcho_retrieval_knobs.py b/tests/test_honcho_retrieval_knobs.py new file mode 100644 index 000000000000..7ff7cfa9e2fb --- /dev/null +++ b/tests/test_honcho_retrieval_knobs.py @@ -0,0 +1,129 @@ +"""Tests for the Honcho representation retrieval knobs. + +Honcho splits its conclusion budget three ways, and only the semantic slice is +driven by the query. These knobs let a deployment collapse the budget onto that +slice. The load-bearing property is the last test in each class: leaving the +keys unset must send nothing, so upgrading changes no behaviour. +""" + +import json + +from plugins.memory.honcho.client import HonchoClientConfig +from plugins.memory.honcho.session import HonchoSessionManager +from plugins.memory.config_schema import ( + KIND_BOOL, + KIND_NUMBER, + get_provider_config_schema, +) + + +RETRIEVAL_KEYS = { + "searchTopK", + "searchMaxDistance", + "maxConclusions", + "includeMostFrequent", +} + + +def _write(tmp_path, host_block, root=None): + config_path = tmp_path / "config.json" + payload = { + "apiKey": "test-api-key-12345", + "hosts": {"hermes": {"workspace": "w", "aiPeer": "a", "peerName": "p", + **host_block}}, + } + payload.update(root or {}) + config_path.write_text(json.dumps(payload)) + return HonchoClientConfig.from_global_config(host="hermes", config_path=config_path) + + +class TestRetrievalSchema: + """The knobs are declared, so the setup wizard and config UI can reach them.""" + + def test_keys_are_declared(self): + provider = get_provider_config_schema("honcho") + assert provider is not None + assert RETRIEVAL_KEYS <= {field.key for field in provider.fields} + + def test_kinds(self): + provider = get_provider_config_schema("honcho") + assert provider is not None + by_key = {f.key: f for f in provider.fields} + assert by_key["searchTopK"].kind == KIND_NUMBER + assert by_key["maxConclusions"].kind == KIND_NUMBER + assert by_key["searchMaxDistance"].kind == KIND_NUMBER + assert by_key["includeMostFrequent"].kind == KIND_BOOL + + def test_stay_out_of_the_inline_panel(self): + """Tuning knobs belong in the modal, not the compact panel.""" + provider = get_provider_config_schema("honcho") + assert provider is not None + assert not (RETRIEVAL_KEYS & {f.key for f in provider.inline_fields()}) + + +class TestRetrievalConfigParsing: + def test_values_parse(self, tmp_path): + cfg = _write(tmp_path, { + "searchTopK": 12, + "maxConclusions": 12, + "searchMaxDistance": 0.65, + "includeMostFrequent": False, + }) + assert cfg.search_top_k == 12 + assert cfg.max_conclusions == 12 + assert cfg.search_max_distance == 0.65 + assert cfg.include_most_frequent is False + + def test_host_block_wins_over_root(self, tmp_path): + cfg = _write(tmp_path, {"searchTopK": 6}, root={"searchTopK": 40}) + assert cfg.search_top_k == 6 + + def test_root_applies_when_host_is_silent(self, tmp_path): + cfg = _write(tmp_path, {}, root={"searchTopK": 40}) + assert cfg.search_top_k == 40 + + def test_unparseable_values_fall_back_to_none(self, tmp_path): + """A typo must not crash the provider at startup.""" + cfg = _write(tmp_path, {"searchTopK": "abc", "searchMaxDistance": "x"}) + assert cfg.search_top_k is None + assert cfg.search_max_distance is None + + def test_unset_stays_none(self, tmp_path): + cfg = _write(tmp_path, {}) + assert cfg.search_top_k is None + assert cfg.search_max_distance is None + assert cfg.max_conclusions is None + assert cfg.include_most_frequent is None + + +class TestRetrievalKwargs: + """What actually reaches peer.context() and peer.representation().""" + + def test_configured_values_are_forwarded(self, tmp_path): + cfg = _write(tmp_path, { + "searchTopK": 12, + "maxConclusions": 12, + "searchMaxDistance": 0.65, + }) + mgr = HonchoSessionManager(config=cfg) + assert mgr._retrieval_kwargs() == { + "search_top_k": 12, + "max_conclusions": 12, + "search_max_distance": 0.65, + } + + def test_false_is_forwarded_not_dropped(self, tmp_path): + """include_most_frequent is tri-state: False differs from unset.""" + cfg = _write(tmp_path, {"includeMostFrequent": False}) + mgr = HonchoSessionManager(config=cfg) + assert mgr._retrieval_kwargs() == {"include_most_frequent": False} + + def test_unset_sends_nothing(self, tmp_path): + """The compatibility guarantee: no keys configured, no parameters sent.""" + cfg = _write(tmp_path, {}) + mgr = HonchoSessionManager(config=cfg) + assert mgr._retrieval_kwargs() == {} + + def test_no_config_at_all_sends_nothing(self): + mgr = HonchoSessionManager() + assert mgr._retrieval_kwargs() == {}