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
63 changes: 59 additions & 4 deletions plugins/memory/hindsight/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
import threading

from datetime import datetime, timezone
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional

from agent.memory_provider import MemoryProvider
from hermes_constants import get_hermes_home
Expand All @@ -53,7 +53,7 @@
_DEFAULT_API_URL = "https://api.hindsight.vectorize.io"
_DEFAULT_LOCAL_URL = "http://localhost:8888"
# Keep in sync with tools/lazy_deps.py ("memory.hindsight") and plugin.yaml.
_MIN_CLIENT_VERSION = "0.6.1"
_MIN_CLIENT_VERSION = "0.8.5"
_DEFAULT_TIMEOUT = 120 # seconds — cloud API can take 30-40s per request
_DEFAULT_IDLE_TIMEOUT = 300 # seconds — Hindsight embedded daemon default
# Mirrors hindsight-integrations/openclaw — Hindsight 0.5.0 added
Expand Down Expand Up @@ -479,6 +479,48 @@ def _normalize_observation_scopes(value: Any) -> Any:
return None


_VALID_MIN_SCORE_KEYS = frozenset({"semantic", "keyword", "reranker", "final"})


def _normalize_min_scores(value: Any) -> Optional[Dict[str, float]]:
"""Validate and normalize a ``recall_min_scores`` config value.

Returns a ``{stage: floor}`` dict ready to pass as ``min_scores`` to
``client.arecall()``, or ``None`` if the config is unset / entirely invalid
(fail-open: no relevance floor applied).

Only the four known stage keys are accepted (0.8.5 client raises
``ValueError`` on unknown keys). Non-numeric values are dropped with a
``logger.warning``.
"""
if value is None:
return None
if not isinstance(value, dict):
logger.warning("recall_min_scores: expected dict, got %s — ignoring", type(value).__name__)
return None

result: Dict[str, float] = {}
for key, raw in value.items():
key_str = str(key)
if key_str not in _VALID_MIN_SCORE_KEYS:
logger.warning(
"recall_min_scores: unknown key %r (must be one of %s) — dropping",
key_str,
", ".join(sorted(_VALID_MIN_SCORE_KEYS)),
)
continue
try:
result[key_str] = float(raw)
except (TypeError, ValueError):
logger.warning(
"recall_min_scores: key %r has non-numeric value %r — dropping",
key_str,
raw,
)

return result or None


def _utc_timestamp() -> str:
"""Return current UTC timestamp in ISO-8601 with milliseconds and Z suffix."""
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
Expand Down Expand Up @@ -1003,6 +1045,7 @@ def get_config_schema(self):
{"key": "retain_context", "description": "Context label for retained memories", "default": "conversation between Hermes Agent and the User"},
{"key": "recall_max_tokens", "description": "Maximum tokens for recall results", "default": 4096},
{"key": "recall_max_input_chars", "description": "Maximum input query length for auto-recall", "default": 800},
{"key": "recall_min_scores", "description": "Relevance score floors applied to RECALL ONLY (not reflect — server-side limitation). Dict mapping stage name to minimum score, e.g. {\"reranker\": 0.01}. Valid stages: semantic, keyword, reranker, final.", "default": {}},
{"key": "recall_prompt_preamble", "description": "Custom preamble for recalled memories in context"},
{"key": "timeout", "description": "API request timeout in seconds", "default": _DEFAULT_TIMEOUT},
{"key": "idle_timeout", "description": "Embedded daemon idle timeout in seconds (0 disables auto-shutdown)", "default": _DEFAULT_IDLE_TIMEOUT, "when": {"mode": "local_embedded"}},
Expand Down Expand Up @@ -1351,6 +1394,13 @@ def initialize(self, session_id: str, **kwargs) -> None:
self._recall_types = list(configured_types) or ["observation"]
self._recall_prompt_preamble = self._config.get("recall_prompt_preamble", "")
self._recall_max_input_chars = int(self._config.get("recall_max_input_chars", 800))

# Min-scores relevance floor (RECALL ONLY — server ReflectRequest has
# no min_scores field, so this does NOT apply to reflect paths).
self._recall_min_scores = _normalize_min_scores(
self._config.get("recall_min_scores")
)

self._retain_async = self._config.get("retain_async", True)

_client_version = "unknown"
Expand All @@ -1366,10 +1416,11 @@ def initialize(self, session_id: str, **kwargs) -> None:
self._bank_id_template, self._agent_identity, self._agent_workspace,
self._platform, self._user_id, self._bank_id)
logger.debug("Hindsight config: auto_retain=%s, auto_recall=%s, retain_every_n=%d, "
"retain_async=%s, retain_context=%s, recall_max_tokens=%d, recall_max_input_chars=%d, tags=%s, recall_tags=%s",
"retain_async=%s, retain_context=%s, recall_max_tokens=%d, recall_max_input_chars=%d, "
"tags=%s, recall_tags=%s, recall_min_scores=%s",
self._auto_retain, self._auto_recall, self._retain_every_n_turns,
self._retain_async, self._retain_context, self._recall_max_tokens, self._recall_max_input_chars,
self._tags, self._recall_tags)
self._tags, self._recall_tags, self._recall_min_scores)

# For local mode, start the embedded daemon in the background so it
# doesn't block the chat. Redirect stdout/stderr to a log file to
Expand Down Expand Up @@ -1511,6 +1562,8 @@ def _run():
recall_kwargs["tags_match"] = self._recall_tags_match
if self._recall_types:
recall_kwargs["types"] = self._recall_types
if self._recall_min_scores is not None:
recall_kwargs["min_scores"] = self._recall_min_scores
logger.debug("Prefetch: calling recall (bank=%s, query_len=%d, budget=%s)",
self._bank_id, len(query), self._budget)
resp = self._run_hindsight_operation(lambda client: client.arecall(**recall_kwargs))
Expand Down Expand Up @@ -1740,6 +1793,8 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
recall_kwargs["tags_match"] = self._recall_tags_match
if self._recall_types:
recall_kwargs["types"] = self._recall_types
if self._recall_min_scores is not None:
recall_kwargs["min_scores"] = self._recall_min_scores
logger.debug("Tool hindsight_recall: bank=%s, query_len=%d, budget=%s",
self._bank_id, len(query), self._budget)
resp = self._run_hindsight_operation(lambda client: client.arecall(**recall_kwargs))
Expand Down
2 changes: 1 addition & 1 deletion plugins/memory/hindsight/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: hindsight
version: 1.0.0
description: "Hindsight — long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval."
pip_dependencies:
- "hindsight-client>=0.6.1"
- "hindsight-client>=0.8.5"
requires_env: []
hooks:
- on_session_end
120 changes: 120 additions & 0 deletions tests/plugins/memory/test_hindsight_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@
_load_config,
_build_embedded_profile_env,
_normalize_observation_scopes,
_normalize_min_scores,
_normalize_retain_tags,
_resolve_bank_id_template,
_sanitize_bank_segment,
_VALID_MIN_SCORE_KEYS,
)


Expand Down Expand Up @@ -1819,3 +1821,121 @@ def test_save_config_sets_owner_only_permissions(tmp_path):
assert config_file.exists()
mode = stat.S_IMODE(config_file.stat().st_mode)
assert mode == 0o600, f"Expected 0o600 (owner-only), got {oct(mode)}"


# ---------------------------------------------------------------------------
# _normalize_min_scores unit tests
# ---------------------------------------------------------------------------


class TestNormalizeMinScores:
"""Validate the _normalize_min_scores helper (fail-open whitelist)."""

def test_none_returns_none(self):
assert _normalize_min_scores(None) is None

def test_valid_dict_returned(self):
result = _normalize_min_scores({"reranker": 0.01})
assert result == {"reranker": 0.01}

def test_all_valid_keys_accepted(self):
value = {"semantic": 0.1, "keyword": 0.2, "reranker": 0.01, "final": 0.5}
result = _normalize_min_scores(value)
assert result == value

def test_unknown_key_dropped_with_warning(self, caplog):
result = _normalize_min_scores({"reranker": 0.01, "rerank": 0.5})
assert result == {"reranker": 0.01}
assert any("unknown key" in rec.message.lower() for rec in caplog.records)

def test_non_numeric_value_dropped_with_warning(self, caplog):
result = _normalize_min_scores({"reranker": 0.01, "semantic": "high"})
assert result == {"reranker": 0.01}
assert any("non-numeric" in rec.message.lower() for rec in caplog.records)

def test_all_invalid_returns_none(self, caplog):
result = _normalize_min_scores({"bad_key": 0.5})
assert result is None
# At least one warning logged
assert any(rec.levelname == "WARNING" for rec in caplog.records)

def test_type_error_value_dropped(self, caplog):
"""A list value should be dropped as non-numeric."""
result = _normalize_min_scores({"reranker": [1, 2]})
assert result is None # "reranker" was the only key, value is invalid
assert any("non-numeric" in rec.message.lower() for rec in caplog.records)

def test_not_a_dict_returns_none(self, caplog):
result = _normalize_min_scores("reranker:0.01")
assert result is None
assert any("expected dict" in rec.message.lower() for rec in caplog.records)

def test_valid_keys_constant(self):
assert _VALID_MIN_SCORE_KEYS == {"semantic", "keyword", "reranker", "final"}

def test_int_value_coerced_to_float(self):
result = _normalize_min_scores({"reranker": 1})
assert result == {"reranker": 1.0}
assert isinstance(result["reranker"], float)


# ---------------------------------------------------------------------------
# Recall min_scores wiring tests
# ---------------------------------------------------------------------------


class TestRecallMinScores:
"""Verify recall_min_scores reaches arecall at both call sites and that
malformed config is dropped (fail-open)."""

def test_prefetch_passes_min_scores_when_configured(self, provider_with_config):
p = provider_with_config(recall_min_scores={"reranker": 0.01})
p.queue_prefetch("test query")
if p._prefetch_thread:
p._prefetch_thread.join(timeout=5.0)

call_kwargs = p._client.arecall.call_args.kwargs
assert call_kwargs["min_scores"] == {"reranker": 0.01}

def test_prefetch_omits_min_scores_when_unset(self, provider_with_config):
p = provider_with_config()
p.queue_prefetch("test query")
if p._prefetch_thread:
p._prefetch_thread.join(timeout=5.0)

call_kwargs = p._client.arecall.call_args.kwargs
assert "min_scores" not in call_kwargs

def test_hindsight_recall_tool_passes_min_scores(self, provider_with_config):
p = provider_with_config(recall_min_scores={"reranker": 0.02, "final": 0.1})
p.handle_tool_call("hindsight_recall", {"query": "test"})
call_kwargs = p._client.arecall.call_args.kwargs
assert call_kwargs["min_scores"] == {"reranker": 0.02, "final": 0.1}

def test_hindsight_recall_tool_omits_min_scores_when_unset(self, provider_with_config):
p = provider_with_config()
p.handle_tool_call("hindsight_recall", {"query": "test"})
call_kwargs = p._client.arecall.call_args.kwargs
assert "min_scores" not in call_kwargs

def test_malformed_min_scores_unknown_key(self, provider_with_config, caplog):
"""Unknown key is dropped; min_scores is NOT passed to arecall."""
p = provider_with_config(
recall_min_scores={"unknown_stage": 0.5}
)
assert p._recall_min_scores is None # all entries dropped
p.handle_tool_call("hindsight_recall", {"query": "test"})
call_kwargs = p._client.arecall.call_args.kwargs
assert "min_scores" not in call_kwargs
assert any("unknown key" in rec.message.lower() for rec in caplog.records)

def test_malformed_min_scores_non_numeric(self, provider_with_config, caplog):
"""Non-numeric value is dropped; min_scores is NOT passed to arecall."""
p = provider_with_config(
recall_min_scores={"reranker": "high"}
)
assert p._recall_min_scores is None # all entries dropped
p.handle_tool_call("hindsight_recall", {"query": "test"})
call_kwargs = p._client.arecall.call_args.kwargs
assert "min_scores" not in call_kwargs
assert any("non-numeric" in rec.message.lower() for rec in caplog.records)
2 changes: 1 addition & 1 deletion tools/lazy_deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@

# ─── Memory providers ──────────────────────────────────────────────────
"memory.honcho": ("honcho-ai==2.0.1",),
"memory.hindsight": ("hindsight-client==0.6.1",),
"memory.hindsight": ("hindsight-client==0.8.5",),
# supermemory + mem0 are opt-in cloud memory providers with their own
# SDKs. On the published Docker image the agent venv is sealed
# (HERMES_DISABLE_LAZY_INSTALLS=1) and lazy installs are redirected to the
Expand Down