Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion plugins/memory/honcho/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand Down
71 changes: 71 additions & 0 deletions plugins/memory/honcho/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please validate this optional integer against the pinned SDK contract before forwarding it. honcho-ai==2.2.0 requires search_top_k and max_conclusions in 1..100; values such as 0 or 101 make the later SDK call fail and the broad retrieval exception path returns no representation.

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bool("false") is True, so a hand-authored Honcho JSON configuration cannot reliably disable this setting. Parse explicit boolean strings (while preserving None as omitted) and add a regression test for "false".

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):
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
47 changes: 47 additions & 0 deletions plugins/memory/honcho/config_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
39 changes: 36 additions & 3 deletions plugins/memory/honcho/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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)

Expand Down
129 changes: 129 additions & 0 deletions tests/test_honcho_retrieval_knobs.py
Original file line number Diff line number Diff line change
@@ -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,
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This verifies the helper only. Please add recording-peer coverage for _fetch_peer_context() so the normal peer.context() path and the peer.representation() fallback are both asserted to receive search_query plus these retrieval kwargs.

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() == {}