From 0d0ba19f28d9ed040c7f1386802bb32e4b384224 Mon Sep 17 00:00:00 2001 From: poruru-code Date: Fri, 24 Apr 2026 00:42:23 +0900 Subject: [PATCH 1/2] feat(hindsight): add prefetch metadata scope --- plugins/memory/hindsight/README.md | 4 + plugins/memory/hindsight/__init__.py | 135 ++++++++++- .../plugins/memory/test_hindsight_provider.py | 223 +++++++++++++++++- 3 files changed, 352 insertions(+), 10 deletions(-) diff --git a/plugins/memory/hindsight/README.md b/plugins/memory/hindsight/README.md index 3fbdc2aba43ea..7f985681481f2 100644 --- a/plugins/memory/hindsight/README.md +++ b/plugins/memory/hindsight/README.md @@ -69,6 +69,8 @@ Config file: `~/.hermes/hindsight/config.json` |-----|---------|-------------| | `recall_budget` | `mid` | Recall thoroughness: `low` / `mid` / `high` | | `recall_prefetch_method` | `recall` | Auto-recall method: `recall` (raw facts) or `reflect` (LLM synthesis) | +| `recall_prefetch_metadata_fields` | — | Auto-prefetch recall only: local metadata scope filter fields (comma-separated; allowed: `source`, `platform`, `chat_id`, `chat_type`, `thread_id`, `agent_identity`) | +| `recall_prefetch_metadata_strict` | `false` | Auto-prefetch recall only: when `true`, keep only fully scoped exact matches; when `false`, fully unscoped results are still allowed, but partial matches are dropped | | `recall_max_tokens` | `4096` | Maximum tokens for recall results | | `recall_max_input_chars` | `800` | Maximum input query length for auto-recall | | `recall_prompt_preamble` | — | Custom preamble for recalled memories in context | @@ -76,6 +78,8 @@ Config file: `~/.hermes/hindsight/config.json` | `recall_tags_match` | `any` | Tag matching mode: `any` / `all` / `any_strict` / `all_strict` | | `auto_recall` | `true` | Automatically recall memories before each turn | +`recall_prefetch_metadata_fields` and `recall_prefetch_metadata_strict` apply only to automatic prefetch recall. They do not affect the manual `hindsight_recall` tool and are ignored when `recall_prefetch_method=reflect`. Filtering is local and best-effort; if the selected fields resolve to an empty active scope for the current session, it becomes a no-op for that prefetch run. + ### Retain | Key | Default | Description | diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 2b233e265caa1..0437d9c4275f9 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -41,6 +41,15 @@ _DEFAULT_LOCAL_URL = "http://localhost:8888" _MIN_CLIENT_VERSION = "0.4.22" _VALID_BUDGETS = {"low", "mid", "high"} +_PREFETCH_METADATA_FIELDS = ( + "source", + "platform", + "chat_id", + "chat_type", + "thread_id", + "agent_identity", +) +_TRUTHY_CONFIG_VALUES = {"1", "true", "yes", "on"} _PROVIDER_DEFAULT_MODELS = { "openai": "gpt-4o-mini", "anthropic": "claude-haiku-4-5", @@ -228,6 +237,43 @@ def _normalize_retain_tags(value: Any) -> List[str]: return normalized +def _normalize_prefetch_metadata_fields(value: Any) -> List[str]: + """Normalize prefetch metadata field config to an allowed deduplicated list.""" + if value is None: + return [] + + raw_items: list[Any] + if isinstance(value, list): + raw_items = value + elif isinstance(value, str): + text = value.strip() + if not text: + return [] + if text.startswith("["): + try: + parsed = json.loads(text) + except Exception: + parsed = None + if isinstance(parsed, list): + raw_items = parsed + else: + raw_items = text.split(",") + else: + raw_items = text.split(",") + else: + raw_items = [value] + + normalized = [] + seen = set() + for item in raw_items: + field = str(item).strip() + if not field or field in seen or field not in _PREFETCH_METADATA_FIELDS: + continue + seen.add(field) + normalized.append(field) + return normalized + + 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") @@ -289,6 +335,8 @@ def __init__(self): self._recall_types: list[str] | None = None self._recall_prompt_preamble = "" self._recall_max_input_chars = 800 + self._recall_prefetch_metadata_fields: List[str] = [] + self._recall_prefetch_metadata_strict = False # Bank self._bank_mission = "" @@ -491,6 +539,8 @@ def get_config_schema(self): {"key": "recall_budget", "description": "Recall thoroughness", "default": "mid", "choices": ["low", "mid", "high"]}, {"key": "memory_mode", "description": "Memory integration mode", "default": "hybrid", "choices": ["hybrid", "context", "tools"]}, {"key": "recall_prefetch_method", "description": "Auto-recall method", "default": "recall", "choices": ["recall", "reflect"]}, + {"key": "recall_prefetch_metadata_fields", "description": "Automatically require these session-stable retained metadata fields to match during auto-prefetch recall (comma-separated: source, platform, chat_id, chat_type, thread_id, agent_identity)", "default": ""}, + {"key": "recall_prefetch_metadata_strict", "description": "If true, auto-prefetch keeps only results whose selected metadata fields are all present and equal; if false, fully unscoped results are still allowed, but partial matches are dropped", "default": False}, {"key": "retain_tags", "description": "Default tags applied to retained memories (comma-separated)", "default": ""}, {"key": "retain_source", "description": "Metadata source value attached to retained memories", "default": ""}, {"key": "retain_user_prefix", "description": "Label used before user turns in retained transcripts", "default": "User"}, @@ -630,6 +680,13 @@ def initialize(self, session_id: str, **kwargs) -> None: self._recall_types = self._config.get("recall_types") or None self._recall_prompt_preamble = self._config.get("recall_prompt_preamble", "") self._recall_max_input_chars = int(self._config.get("recall_max_input_chars", 800)) + self._recall_prefetch_metadata_fields = _normalize_prefetch_metadata_fields( + self._config.get("recall_prefetch_metadata_fields") + ) + strict_value = self._config.get("recall_prefetch_metadata_strict", False) + if isinstance(strict_value, str): + strict_value = strict_value.strip().lower() in _TRUTHY_CONFIG_VALUES + self._recall_prefetch_metadata_strict = bool(strict_value) self._retain_async = self._config.get("retain_async", True) _client_version = "unknown" @@ -641,10 +698,10 @@ def initialize(self, session_id: str, **kwargs) -> None: logger.info("Hindsight initialized: mode=%s, api_url=%s, bank=%s, budget=%s, memory_mode=%s, prefetch_method=%s, client=%s", self._mode, self._api_url, self._bank_id, self._budget, self._memory_mode, self._prefetch_method, _client_version) 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, prefetch_metadata_fields=%s, prefetch_metadata_strict=%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_prefetch_metadata_fields, self._recall_prefetch_metadata_strict) # 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 @@ -761,6 +818,56 @@ def prefetch(self, query: str, *, session_id: str = "") -> str: ) return f"{header}\n\n{result}" + def _build_prefetch_metadata_scope(self) -> Dict[str, str]: + current_values = { + "source": self._retain_source, + "platform": self._platform, + "chat_id": self._chat_id, + "chat_type": self._chat_type, + "thread_id": self._thread_id, + "agent_identity": self._agent_identity, + } + scope: Dict[str, str] = {} + for field in self._recall_prefetch_metadata_fields: + value = current_values.get(field, "") + if value: + scope[field] = value + return scope + + def _filter_prefetch_results_by_metadata( + self, + results: List[Any], + *, + active_scope: Dict[str, str] | None = None, + ) -> List[Any]: + if not results or not self._recall_prefetch_metadata_fields: + return results + + scope = active_scope if active_scope is not None else self._build_prefetch_metadata_scope() + if not scope: + return results + + filtered: List[Any] = [] + scope_keys = tuple(scope.keys()) + for result in results: + metadata = getattr(result, "metadata", None) + if not isinstance(metadata, dict): + metadata = {} + + present_keys = [ + key for key in scope_keys + if metadata.get(key) not in (None, "") + ] + if not present_keys: + if not self._recall_prefetch_metadata_strict: + filtered.append(result) + continue + if len(present_keys) != len(scope_keys): + continue + if all(str(metadata[key]) == scope[key] for key in scope_keys): + filtered.append(result) + return filtered + def queue_prefetch(self, query: str, *, session_id: str = "") -> None: if self._memory_mode == "tools": logger.debug("Prefetch: skipped (tools-only mode)") @@ -792,12 +899,26 @@ def _run(): logger.debug("Prefetch: calling recall (bank=%s, query_len=%d, budget=%s)", self._bank_id, len(query), self._budget) resp = _run_sync(client.arecall(**recall_kwargs)) - num_results = len(resp.results) if resp.results else 0 + results = list(resp.results or []) + num_results = len(results) logger.debug("Prefetch: recall returned %d results", num_results) - text = "\n".join(f"- {r.text}" for r in resp.results if r.text) if resp.results else "" - if text: - with self._prefetch_lock: - self._prefetch_result = text + active_scope = self._build_prefetch_metadata_scope() + filtered_results = self._filter_prefetch_results_by_metadata( + results, + active_scope=active_scope, + ) + if self._recall_prefetch_metadata_fields: + if active_scope: + logger.debug( + "Prefetch: metadata filter kept %d/%d results", + len(filtered_results), + num_results, + ) + else: + logger.debug("Prefetch: metadata filter disabled (empty active scope)") + text = "\n".join(f"- {r.text}" for r in filtered_results if r.text) if filtered_results else "" + with self._prefetch_lock: + self._prefetch_result = text except Exception as e: logger.debug("Hindsight prefetch failed: %s", e, exc_info=True) diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index db86f7626fa7a..8aa059ad023b0 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -19,6 +19,7 @@ REFLECT_SCHEMA, RETAIN_SCHEMA, _load_config, + _normalize_prefetch_metadata_fields, _normalize_retain_tags, ) @@ -74,6 +75,12 @@ async def _aretain( return client +def _run_prefetch(provider, query="test query"): + provider.queue_prefetch(query) + if provider._prefetch_thread: + provider._prefetch_thread.join(timeout=5.0) + + class _FakeSessionDB: def __init__(self, messages=None): self._messages = list(messages or []) @@ -147,6 +154,18 @@ def test_normalize_retain_tags_accepts_json_array_string(): assert _normalize_retain_tags(value) == ["agent:fakeassistantname", "source_system:hermes-agent"] +def test_normalize_prefetch_metadata_fields_accepts_csv_and_allowlist(): + assert _normalize_prefetch_metadata_fields("chat_id, thread_id, user_name, chat_id") == [ + "chat_id", + "thread_id", + ] + + +def test_normalize_prefetch_metadata_fields_accepts_json_array_string(): + value = json.dumps(["source", "platform", "chat_name"]) + assert _normalize_prefetch_metadata_fields(value) == ["source", "platform"] + + # --------------------------------------------------------------------------- # Schema tests # --------------------------------------------------------------------------- @@ -203,6 +222,8 @@ def test_custom_config_values(self, provider_with_config): retain_source="hermes", retain_user_prefix="User (fakeusername)", retain_assistant_prefix="Assistant (fakeassistantname)", + recall_prefetch_metadata_fields=["chat_id", "thread_id"], + recall_prefetch_metadata_strict=True, recall_tags=["recall-tag"], recall_tags_match="all", auto_retain=False, @@ -221,6 +242,8 @@ def test_custom_config_values(self, provider_with_config): assert p._retain_source == "hermes" assert p._retain_user_prefix == "User (fakeusername)" assert p._retain_assistant_prefix == "Assistant (fakeassistantname)" + assert p._recall_prefetch_metadata_fields == ["chat_id", "thread_id"] + assert p._recall_prefetch_metadata_strict is True assert p._recall_tags == ["recall-tag"] assert p._recall_tags_match == "all" assert p._auto_retain is False @@ -250,6 +273,10 @@ def test_config_from_env_fallback(self, tmp_path, monkeypatch): assert cfg["banks"]["hermes"]["bankId"] == "env-bank" assert cfg["banks"]["hermes"]["budget"] == "high" + def test_default_prefetch_metadata_config(self, provider): + assert provider._recall_prefetch_metadata_fields == [] + assert provider._recall_prefetch_metadata_strict is False + # --------------------------------------------------------------------------- # Tool handler tests @@ -365,6 +392,26 @@ def test_recall_error_handling(self, provider): )) assert "error" in result + def test_recall_tool_ignores_prefetch_metadata_scope(self, provider_with_config): + p = provider_with_config( + recall_prefetch_metadata_fields=["chat_id"], + recall_prefetch_metadata_strict=True, + ) + p._chat_id = "current-chat" + p._client.arecall = AsyncMock( + return_value=SimpleNamespace( + results=[ + SimpleNamespace( + text="Foreign memory", + metadata={"chat_id": "other-chat"}, + ) + ] + ) + ) + + result = json.loads(p.handle_tool_call("hindsight_recall", {"query": "test"})) + assert result["result"] == "1. Foreign memory" + # --------------------------------------------------------------------------- # Prefetch tests @@ -427,9 +474,7 @@ def test_queue_prefetch_passes_recall_params(self, provider_with_config): recall_max_tokens=1024, recall_types=["world"], ) - p.queue_prefetch("test query") - if p._prefetch_thread: - p._prefetch_thread.join(timeout=5.0) + _run_prefetch(p) call_kwargs = p._client.arecall.call_args.kwargs assert call_kwargs["max_tokens"] == 1024 @@ -437,6 +482,177 @@ def test_queue_prefetch_passes_recall_params(self, provider_with_config): assert call_kwargs["tags_match"] == "all" assert call_kwargs["types"] == ["world"] + def test_queue_prefetch_default_config_leaves_results_unfiltered(self, provider): + provider._client.arecall = AsyncMock( + return_value=SimpleNamespace( + results=[ + SimpleNamespace( + text="Foreign memory", + metadata={"chat_id": "other-chat", "thread_id": "other-thread"}, + ), + SimpleNamespace(text="Unscoped memory"), + ] + ) + ) + + _run_prefetch(provider) + + assert provider._recall_prefetch_metadata_fields == [] + assert provider._prefetch_result.splitlines() == ["- Foreign memory", "- Unscoped memory"] + + def test_queue_prefetch_filters_results_by_metadata(self, provider_with_config): + p = provider_with_config(recall_prefetch_metadata_fields=["chat_id", "thread_id"]) + p._chat_id = "current-chat" + p._thread_id = "current-thread" + p._client.arecall = AsyncMock( + return_value=SimpleNamespace( + results=[ + SimpleNamespace( + text="Keep me", + metadata={"chat_id": "current-chat", "thread_id": "current-thread"}, + ), + SimpleNamespace( + text="Drop me", + metadata={"chat_id": "other-chat", "thread_id": "other-thread"}, + ), + ] + ) + ) + + _run_prefetch(p) + + assert p._prefetch_result == "- Keep me" + + def test_queue_prefetch_keeps_unscoped_results_when_metadata_scope_non_strict(self, provider_with_config): + p = provider_with_config(recall_prefetch_metadata_fields=["chat_id", "thread_id"]) + p._chat_id = "current-chat" + p._thread_id = "current-thread" + p._client.arecall = AsyncMock( + return_value=SimpleNamespace( + results=[ + SimpleNamespace( + text="Scoped memory", + metadata={"chat_id": "current-chat", "thread_id": "current-thread"}, + ), + SimpleNamespace(text="Unscoped memory"), + SimpleNamespace( + text="Foreign memory", + metadata={"chat_id": "other-chat", "thread_id": "other-thread"}, + ), + ] + ) + ) + + _run_prefetch(p) + + assert p._prefetch_result.splitlines() == ["- Scoped memory", "- Unscoped memory"] + + def test_queue_prefetch_drops_unscoped_results_when_metadata_scope_strict(self, provider_with_config): + p = provider_with_config( + recall_prefetch_metadata_fields=["chat_id", "thread_id"], + recall_prefetch_metadata_strict=True, + ) + p._chat_id = "current-chat" + p._thread_id = "current-thread" + p._client.arecall = AsyncMock( + return_value=SimpleNamespace( + results=[ + SimpleNamespace( + text="Scoped memory", + metadata={"chat_id": "current-chat", "thread_id": "current-thread"}, + ), + SimpleNamespace(text="Unscoped memory"), + ] + ) + ) + + _run_prefetch(p) + + assert p._prefetch_result == "- Scoped memory" + + def test_queue_prefetch_filtered_empty_stays_empty_without_retry(self, provider_with_config): + p = provider_with_config( + recall_prefetch_metadata_fields=["chat_id", "thread_id"], + recall_prefetch_metadata_strict=True, + ) + p._chat_id = "current-chat" + p._thread_id = "current-thread" + p._client.arecall = AsyncMock( + return_value=SimpleNamespace( + results=[ + SimpleNamespace( + text="Foreign memory", + metadata={"chat_id": "other-chat", "thread_id": "other-thread"}, + ), + SimpleNamespace(text="Unscoped memory"), + ] + ) + ) + + _run_prefetch(p) + + assert p._client.arecall.await_count == 1 + assert p._prefetch_result == "" + assert p.prefetch("test query") == "" + + def test_queue_prefetch_drops_partial_metadata_results(self, provider_with_config): + p = provider_with_config(recall_prefetch_metadata_fields=["chat_id", "thread_id"]) + p._chat_id = "current-chat" + p._thread_id = "current-thread" + p._client.arecall = AsyncMock( + return_value=SimpleNamespace( + results=[ + SimpleNamespace( + text="Scoped memory", + metadata={"chat_id": "current-chat", "thread_id": "current-thread"}, + ), + SimpleNamespace( + text="Partial memory", + metadata={"chat_id": "current-chat"}, + ), + ] + ) + ) + + _run_prefetch(p) + + assert p._prefetch_result == "- Scoped memory" + + def test_queue_prefetch_metadata_scope_is_ignored_for_reflect_method(self, provider_with_config): + p = provider_with_config( + recall_prefetch_method="reflect", + recall_prefetch_metadata_fields=["chat_id"], + recall_prefetch_metadata_strict=True, + ) + p._chat_id = "current-chat" + p._client.areflect = AsyncMock(return_value=SimpleNamespace(text="Synthesized answer")) + + _run_prefetch(p) + + assert p._prefetch_result == "Synthesized answer" + p._client.arecall.assert_not_called() + + def test_queue_prefetch_metadata_scope_is_noop_when_active_scope_empty(self, provider_with_config): + p = provider_with_config( + recall_prefetch_metadata_fields=["thread_id"], + recall_prefetch_metadata_strict=True, + ) + p._client.arecall = AsyncMock( + return_value=SimpleNamespace( + results=[ + SimpleNamespace( + text="Foreign memory", + metadata={"thread_id": "other-thread"}, + ), + SimpleNamespace(text="Unscoped memory"), + ] + ) + ) + + _run_prefetch(p) + + assert p._prefetch_result.splitlines() == ["- Foreign memory", "- Unscoped memory"] + # --------------------------------------------------------------------------- # sync_turn tests @@ -570,6 +786,7 @@ def test_schema_has_all_new_fields(self, provider): "mode", "api_url", "api_key", "llm_provider", "llm_api_key", "llm_model", "bank_id", "bank_mission", "bank_retain_mission", "recall_budget", "memory_mode", "recall_prefetch_method", + "recall_prefetch_metadata_fields", "recall_prefetch_metadata_strict", "retain_tags", "retain_source", "retain_user_prefix", "retain_assistant_prefix", "recall_tags", "recall_tags_match", From 136bf02638d17dc69341ffa1faaf05808dfa6c5b Mon Sep 17 00:00:00 2001 From: poruru-code Date: Sat, 25 Apr 2026 02:53:10 +0900 Subject: [PATCH 2/2] fix(hindsight): use explicit tag filters for prefetch --- plugins/memory/hindsight/README.md | 10 +- plugins/memory/hindsight/__init__.py | 165 +++------------ .../plugins/memory/test_hindsight_provider.py | 196 ++++-------------- .../user-guide/features/memory-providers.md | 4 + 4 files changed, 89 insertions(+), 286 deletions(-) diff --git a/plugins/memory/hindsight/README.md b/plugins/memory/hindsight/README.md index 7f985681481f2..033c22cc2318d 100644 --- a/plugins/memory/hindsight/README.md +++ b/plugins/memory/hindsight/README.md @@ -69,16 +69,20 @@ Config file: `~/.hermes/hindsight/config.json` |-----|---------|-------------| | `recall_budget` | `mid` | Recall thoroughness: `low` / `mid` / `high` | | `recall_prefetch_method` | `recall` | Auto-recall method: `recall` (raw facts) or `reflect` (LLM synthesis) | -| `recall_prefetch_metadata_fields` | — | Auto-prefetch recall only: local metadata scope filter fields (comma-separated; allowed: `source`, `platform`, `chat_id`, `chat_type`, `thread_id`, `agent_identity`) | -| `recall_prefetch_metadata_strict` | `false` | Auto-prefetch recall only: when `true`, keep only fully scoped exact matches; when `false`, fully unscoped results are still allowed, but partial matches are dropped | | `recall_max_tokens` | `4096` | Maximum tokens for recall results | | `recall_max_input_chars` | `800` | Maximum input query length for auto-recall | | `recall_prompt_preamble` | — | Custom preamble for recalled memories in context | | `recall_tags` | — | Tags to filter when searching memories | | `recall_tags_match` | `any` | Tag matching mode: `any` / `all` / `any_strict` / `all_strict` | +| `recall_prefetch_tags` | — | Auto-prefetch recall only: explicit tags to use instead of generic `recall_tags` | +| `recall_prefetch_tags_match` | `any` | Auto-prefetch recall only: tag matching mode for `recall_prefetch_tags` | | `auto_recall` | `true` | Automatically recall memories before each turn | -`recall_prefetch_metadata_fields` and `recall_prefetch_metadata_strict` apply only to automatic prefetch recall. They do not affect the manual `hindsight_recall` tool and are ignored when `recall_prefetch_method=reflect`. Filtering is local and best-effort; if the selected fields resolve to an empty active scope for the current session, it becomes a no-op for that prefetch run. +`recall_prefetch_tags` and `recall_prefetch_tags_match` apply only to automatic prefetch recall. When unset, auto-prefetch falls back to the generic `recall_tags` and `recall_tags_match` settings. They do not affect the manual `hindsight_recall` tool and are ignored when `recall_prefetch_method=reflect`. + +If you want hard tag scoping, use a `_strict` match mode. Hindsight's non-strict `any` and `all` modes can still include untagged memories. + +This design changes only the read path. It does not add implicit session-derived tags, metadata-to-tag bridging, or any write-path changes to auto-retain or manual `hindsight_retain`. As a result, `recall_prefetch_tags` only matches memories that already carry those tags; untagged memories are not automatically backfilled or inferred from metadata. ### Retain diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 0437d9c4275f9..3427e9300f083 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -41,15 +41,7 @@ _DEFAULT_LOCAL_URL = "http://localhost:8888" _MIN_CLIENT_VERSION = "0.4.22" _VALID_BUDGETS = {"low", "mid", "high"} -_PREFETCH_METADATA_FIELDS = ( - "source", - "platform", - "chat_id", - "chat_type", - "thread_id", - "agent_identity", -) -_TRUTHY_CONFIG_VALUES = {"1", "true", "yes", "on"} +_VALID_TAG_MATCH_MODES = {"any", "all", "any_strict", "all_strict"} _PROVIDER_DEFAULT_MODELS = { "openai": "gpt-4o-mini", "anthropic": "claude-haiku-4-5", @@ -200,7 +192,7 @@ def _load_config() -> dict: } -def _normalize_retain_tags(value: Any) -> List[str]: +def _normalize_tags(value: Any) -> List[str]: """Normalize tag config/tool values to a deduplicated list of strings.""" if value is None: return [] @@ -237,41 +229,10 @@ def _normalize_retain_tags(value: Any) -> List[str]: return normalized -def _normalize_prefetch_metadata_fields(value: Any) -> List[str]: - """Normalize prefetch metadata field config to an allowed deduplicated list.""" - if value is None: - return [] - - raw_items: list[Any] - if isinstance(value, list): - raw_items = value - elif isinstance(value, str): - text = value.strip() - if not text: - return [] - if text.startswith("["): - try: - parsed = json.loads(text) - except Exception: - parsed = None - if isinstance(parsed, list): - raw_items = parsed - else: - raw_items = text.split(",") - else: - raw_items = text.split(",") - else: - raw_items = [value] - - normalized = [] - seen = set() - for item in raw_items: - field = str(item).strip() - if not field or field in seen or field not in _PREFETCH_METADATA_FIELDS: - continue - seen.add(field) - normalized.append(field) - return normalized +def _normalize_tags_match(value: Any) -> str: + """Normalize tag match mode to a supported choice.""" + candidate = str(value or "any").strip().lower() or "any" + return candidate if candidate in _VALID_TAG_MATCH_MODES else "any" def _utc_timestamp() -> str: @@ -320,6 +281,8 @@ def __init__(self): self._tags: list[str] | None = None self._recall_tags: list[str] | None = None self._recall_tags_match = "any" + self._recall_prefetch_tags: list[str] | None = None + self._recall_prefetch_tags_match = "any" # Retain controls self._auto_retain = True @@ -335,8 +298,6 @@ def __init__(self): self._recall_types: list[str] | None = None self._recall_prompt_preamble = "" self._recall_max_input_chars = 800 - self._recall_prefetch_metadata_fields: List[str] = [] - self._recall_prefetch_metadata_strict = False # Bank self._bank_mission = "" @@ -539,8 +500,8 @@ def get_config_schema(self): {"key": "recall_budget", "description": "Recall thoroughness", "default": "mid", "choices": ["low", "mid", "high"]}, {"key": "memory_mode", "description": "Memory integration mode", "default": "hybrid", "choices": ["hybrid", "context", "tools"]}, {"key": "recall_prefetch_method", "description": "Auto-recall method", "default": "recall", "choices": ["recall", "reflect"]}, - {"key": "recall_prefetch_metadata_fields", "description": "Automatically require these session-stable retained metadata fields to match during auto-prefetch recall (comma-separated: source, platform, chat_id, chat_type, thread_id, agent_identity)", "default": ""}, - {"key": "recall_prefetch_metadata_strict", "description": "If true, auto-prefetch keeps only results whose selected metadata fields are all present and equal; if false, fully unscoped results are still allowed, but partial matches are dropped", "default": False}, + {"key": "recall_prefetch_tags", "description": "Auto-prefetch recall only: explicit tags to use instead of generic recall_tags when searching memories (comma-separated)", "default": ""}, + {"key": "recall_prefetch_tags_match", "description": "Auto-prefetch recall only: tag matching mode for recall_prefetch_tags", "default": "any", "choices": ["any", "all", "any_strict", "all_strict"]}, {"key": "retain_tags", "description": "Default tags applied to retained memories (comma-separated)", "default": ""}, {"key": "retain_source", "description": "Metadata source value attached to retained memories", "default": ""}, {"key": "retain_user_prefix", "description": "Label used before user turns in retained transcripts", "default": "User"}, @@ -652,13 +613,17 @@ def initialize(self, session_id: str, **kwargs) -> None: self._bank_retain_mission = self._config.get("bank_retain_mission") or None # Tags - self._retain_tags = _normalize_retain_tags( + self._retain_tags = _normalize_tags( self._config.get("retain_tags") or os.environ.get("HINDSIGHT_RETAIN_TAGS", "") ) self._tags = self._retain_tags or None - self._recall_tags = self._config.get("recall_tags") or None - self._recall_tags_match = self._config.get("recall_tags_match", "any") + self._recall_tags = _normalize_tags(self._config.get("recall_tags")) or None + self._recall_tags_match = _normalize_tags_match(self._config.get("recall_tags_match", "any")) + self._recall_prefetch_tags = _normalize_tags(self._config.get("recall_prefetch_tags")) or None + self._recall_prefetch_tags_match = _normalize_tags_match( + self._config.get("recall_prefetch_tags_match", "any") + ) self._retain_source = str( self._config.get("retain_source") or os.environ.get("HINDSIGHT_RETAIN_SOURCE", "") ).strip() @@ -680,13 +645,6 @@ def initialize(self, session_id: str, **kwargs) -> None: self._recall_types = self._config.get("recall_types") or None self._recall_prompt_preamble = self._config.get("recall_prompt_preamble", "") self._recall_max_input_chars = int(self._config.get("recall_max_input_chars", 800)) - self._recall_prefetch_metadata_fields = _normalize_prefetch_metadata_fields( - self._config.get("recall_prefetch_metadata_fields") - ) - strict_value = self._config.get("recall_prefetch_metadata_strict", False) - if isinstance(strict_value, str): - strict_value = strict_value.strip().lower() in _TRUTHY_CONFIG_VALUES - self._recall_prefetch_metadata_strict = bool(strict_value) self._retain_async = self._config.get("retain_async", True) _client_version = "unknown" @@ -698,10 +656,10 @@ def initialize(self, session_id: str, **kwargs) -> None: logger.info("Hindsight initialized: mode=%s, api_url=%s, bank=%s, budget=%s, memory_mode=%s, prefetch_method=%s, client=%s", self._mode, self._api_url, self._bank_id, self._budget, self._memory_mode, self._prefetch_method, _client_version) 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, prefetch_metadata_fields=%s, prefetch_metadata_strict=%s", + "retain_async=%s, retain_context=%s, recall_max_tokens=%d, recall_max_input_chars=%d, tags=%s, recall_tags=%s, prefetch_tags=%s, prefetch_tags_match=%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._recall_prefetch_metadata_fields, self._recall_prefetch_metadata_strict) + self._tags, self._recall_tags, self._recall_prefetch_tags, self._recall_prefetch_tags_match) # 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 @@ -818,56 +776,6 @@ def prefetch(self, query: str, *, session_id: str = "") -> str: ) return f"{header}\n\n{result}" - def _build_prefetch_metadata_scope(self) -> Dict[str, str]: - current_values = { - "source": self._retain_source, - "platform": self._platform, - "chat_id": self._chat_id, - "chat_type": self._chat_type, - "thread_id": self._thread_id, - "agent_identity": self._agent_identity, - } - scope: Dict[str, str] = {} - for field in self._recall_prefetch_metadata_fields: - value = current_values.get(field, "") - if value: - scope[field] = value - return scope - - def _filter_prefetch_results_by_metadata( - self, - results: List[Any], - *, - active_scope: Dict[str, str] | None = None, - ) -> List[Any]: - if not results or not self._recall_prefetch_metadata_fields: - return results - - scope = active_scope if active_scope is not None else self._build_prefetch_metadata_scope() - if not scope: - return results - - filtered: List[Any] = [] - scope_keys = tuple(scope.keys()) - for result in results: - metadata = getattr(result, "metadata", None) - if not isinstance(metadata, dict): - metadata = {} - - present_keys = [ - key for key in scope_keys - if metadata.get(key) not in (None, "") - ] - if not present_keys: - if not self._recall_prefetch_metadata_strict: - filtered.append(result) - continue - if len(present_keys) != len(scope_keys): - continue - if all(str(metadata[key]) == scope[key] for key in scope_keys): - filtered.append(result) - return filtered - def queue_prefetch(self, query: str, *, session_id: str = "") -> None: if self._memory_mode == "tools": logger.debug("Prefetch: skipped (tools-only mode)") @@ -891,32 +799,23 @@ def _run(): "bank_id": self._bank_id, "query": query, "budget": self._budget, "max_tokens": self._recall_max_tokens, } - if self._recall_tags: - recall_kwargs["tags"] = self._recall_tags - recall_kwargs["tags_match"] = self._recall_tags_match + prefetch_tags = self._recall_prefetch_tags or self._recall_tags + prefetch_tags_match = ( + self._recall_prefetch_tags_match + if self._recall_prefetch_tags + else self._recall_tags_match + ) + if prefetch_tags: + recall_kwargs["tags"] = prefetch_tags + recall_kwargs["tags_match"] = prefetch_tags_match if self._recall_types: recall_kwargs["types"] = self._recall_types logger.debug("Prefetch: calling recall (bank=%s, query_len=%d, budget=%s)", self._bank_id, len(query), self._budget) resp = _run_sync(client.arecall(**recall_kwargs)) results = list(resp.results or []) - num_results = len(results) - logger.debug("Prefetch: recall returned %d results", num_results) - active_scope = self._build_prefetch_metadata_scope() - filtered_results = self._filter_prefetch_results_by_metadata( - results, - active_scope=active_scope, - ) - if self._recall_prefetch_metadata_fields: - if active_scope: - logger.debug( - "Prefetch: metadata filter kept %d/%d results", - len(filtered_results), - num_results, - ) - else: - logger.debug("Prefetch: metadata filter disabled (empty active scope)") - text = "\n".join(f"- {r.text}" for r in filtered_results if r.text) if filtered_results else "" + logger.debug("Prefetch: recall returned %d results", len(results)) + text = "\n".join(f"- {r.text}" for r in results if r.text) if results else "" with self._prefetch_lock: self._prefetch_result = text except Exception as e: @@ -989,8 +888,8 @@ def _build_retain_kwargs( kwargs["document_id"] = document_id if retain_async is not None: kwargs["retain_async"] = retain_async - merged_tags = _normalize_retain_tags(self._retain_tags) - for tag in _normalize_retain_tags(tags): + merged_tags = _normalize_tags(self._retain_tags) + for tag in _normalize_tags(tags): if tag not in merged_tags: merged_tags.append(tag) if merged_tags: diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index 8aa059ad023b0..fb14e65160097 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -19,8 +19,7 @@ REFLECT_SCHEMA, RETAIN_SCHEMA, _load_config, - _normalize_prefetch_metadata_fields, - _normalize_retain_tags, + _normalize_tags, ) @@ -142,28 +141,16 @@ def _make(**overrides): return _make -def test_normalize_retain_tags_accepts_csv_and_dedupes(): - assert _normalize_retain_tags("agent:fakeassistantname, source_system:hermes-agent, agent:fakeassistantname") == [ +def test_normalize_tags_accepts_csv_and_dedupes(): + assert _normalize_tags("agent:fakeassistantname, source_system:hermes-agent, agent:fakeassistantname") == [ "agent:fakeassistantname", "source_system:hermes-agent", ] -def test_normalize_retain_tags_accepts_json_array_string(): +def test_normalize_tags_accepts_json_array_string(): value = json.dumps(["agent:fakeassistantname", "source_system:hermes-agent"]) - assert _normalize_retain_tags(value) == ["agent:fakeassistantname", "source_system:hermes-agent"] - - -def test_normalize_prefetch_metadata_fields_accepts_csv_and_allowlist(): - assert _normalize_prefetch_metadata_fields("chat_id, thread_id, user_name, chat_id") == [ - "chat_id", - "thread_id", - ] - - -def test_normalize_prefetch_metadata_fields_accepts_json_array_string(): - value = json.dumps(["source", "platform", "chat_name"]) - assert _normalize_prefetch_metadata_fields(value) == ["source", "platform"] + assert _normalize_tags(value) == ["agent:fakeassistantname", "source_system:hermes-agent"] # --------------------------------------------------------------------------- @@ -222,8 +209,8 @@ def test_custom_config_values(self, provider_with_config): retain_source="hermes", retain_user_prefix="User (fakeusername)", retain_assistant_prefix="Assistant (fakeassistantname)", - recall_prefetch_metadata_fields=["chat_id", "thread_id"], - recall_prefetch_metadata_strict=True, + recall_prefetch_tags=["prefetch-tag"], + recall_prefetch_tags_match="all_strict", recall_tags=["recall-tag"], recall_tags_match="all", auto_retain=False, @@ -242,8 +229,8 @@ def test_custom_config_values(self, provider_with_config): assert p._retain_source == "hermes" assert p._retain_user_prefix == "User (fakeusername)" assert p._retain_assistant_prefix == "Assistant (fakeassistantname)" - assert p._recall_prefetch_metadata_fields == ["chat_id", "thread_id"] - assert p._recall_prefetch_metadata_strict is True + assert p._recall_prefetch_tags == ["prefetch-tag"] + assert p._recall_prefetch_tags_match == "all_strict" assert p._recall_tags == ["recall-tag"] assert p._recall_tags_match == "all" assert p._auto_retain is False @@ -273,9 +260,9 @@ def test_config_from_env_fallback(self, tmp_path, monkeypatch): assert cfg["banks"]["hermes"]["bankId"] == "env-bank" assert cfg["banks"]["hermes"]["budget"] == "high" - def test_default_prefetch_metadata_config(self, provider): - assert provider._recall_prefetch_metadata_fields == [] - assert provider._recall_prefetch_metadata_strict is False + def test_default_prefetch_tag_config(self, provider): + assert provider._recall_prefetch_tags is None + assert provider._recall_prefetch_tags_match == "any" # --------------------------------------------------------------------------- @@ -392,18 +379,18 @@ def test_recall_error_handling(self, provider): )) assert "error" in result - def test_recall_tool_ignores_prefetch_metadata_scope(self, provider_with_config): + def test_recall_tool_ignores_prefetch_tag_config(self, provider_with_config): p = provider_with_config( - recall_prefetch_metadata_fields=["chat_id"], - recall_prefetch_metadata_strict=True, + recall_tags=["manual-tag"], + recall_tags_match="all", + recall_prefetch_tags=["prefetch-tag"], + recall_prefetch_tags_match="all_strict", ) - p._chat_id = "current-chat" p._client.arecall = AsyncMock( return_value=SimpleNamespace( results=[ SimpleNamespace( text="Foreign memory", - metadata={"chat_id": "other-chat"}, ) ] ) @@ -411,6 +398,9 @@ def test_recall_tool_ignores_prefetch_metadata_scope(self, provider_with_config) result = json.loads(p.handle_tool_call("hindsight_recall", {"query": "test"})) assert result["result"] == "1. Foreign memory" + call_kwargs = p._client.arecall.call_args.kwargs + assert call_kwargs["tags"] == ["manual-tag"] + assert call_kwargs["tags_match"] == "all" # --------------------------------------------------------------------------- @@ -497,134 +487,61 @@ def test_queue_prefetch_default_config_leaves_results_unfiltered(self, provider) _run_prefetch(provider) - assert provider._recall_prefetch_metadata_fields == [] + call_kwargs = provider._client.arecall.call_args.kwargs + assert "tags" not in call_kwargs + assert "tags_match" not in call_kwargs assert provider._prefetch_result.splitlines() == ["- Foreign memory", "- Unscoped memory"] - def test_queue_prefetch_filters_results_by_metadata(self, provider_with_config): - p = provider_with_config(recall_prefetch_metadata_fields=["chat_id", "thread_id"]) - p._chat_id = "current-chat" - p._thread_id = "current-thread" - p._client.arecall = AsyncMock( - return_value=SimpleNamespace( - results=[ - SimpleNamespace( - text="Keep me", - metadata={"chat_id": "current-chat", "thread_id": "current-thread"}, - ), - SimpleNamespace( - text="Drop me", - metadata={"chat_id": "other-chat", "thread_id": "other-thread"}, - ), - ] - ) - ) - - _run_prefetch(p) - - assert p._prefetch_result == "- Keep me" - - def test_queue_prefetch_keeps_unscoped_results_when_metadata_scope_non_strict(self, provider_with_config): - p = provider_with_config(recall_prefetch_metadata_fields=["chat_id", "thread_id"]) - p._chat_id = "current-chat" - p._thread_id = "current-thread" - p._client.arecall = AsyncMock( - return_value=SimpleNamespace( - results=[ - SimpleNamespace( - text="Scoped memory", - metadata={"chat_id": "current-chat", "thread_id": "current-thread"}, - ), - SimpleNamespace(text="Unscoped memory"), - SimpleNamespace( - text="Foreign memory", - metadata={"chat_id": "other-chat", "thread_id": "other-thread"}, - ), - ] - ) - ) - - _run_prefetch(p) - - assert p._prefetch_result.splitlines() == ["- Scoped memory", "- Unscoped memory"] - - def test_queue_prefetch_drops_unscoped_results_when_metadata_scope_strict(self, provider_with_config): + def test_queue_prefetch_uses_prefetch_tags_when_configured(self, provider_with_config): p = provider_with_config( - recall_prefetch_metadata_fields=["chat_id", "thread_id"], - recall_prefetch_metadata_strict=True, + recall_tags=["fallback-tag"], + recall_tags_match="all", + recall_prefetch_tags=["prefetch-tag", "team:cli"], + recall_prefetch_tags_match="all_strict", + recall_max_tokens=1024, + recall_types=["world"], ) - p._chat_id = "current-chat" - p._thread_id = "current-thread" p._client.arecall = AsyncMock( return_value=SimpleNamespace( results=[ - SimpleNamespace( - text="Scoped memory", - metadata={"chat_id": "current-chat", "thread_id": "current-thread"}, - ), - SimpleNamespace(text="Unscoped memory"), + SimpleNamespace(text="Tagged memory"), ] ) ) _run_prefetch(p) - assert p._prefetch_result == "- Scoped memory" + call_kwargs = p._client.arecall.call_args.kwargs + assert call_kwargs["tags"] == ["prefetch-tag", "team:cli"] + assert call_kwargs["tags_match"] == "all_strict" + assert call_kwargs["max_tokens"] == 1024 + assert call_kwargs["types"] == ["world"] + assert p._prefetch_result == "- Tagged memory" - def test_queue_prefetch_filtered_empty_stays_empty_without_retry(self, provider_with_config): + def test_queue_prefetch_with_prefetch_tags_does_not_post_filter_results(self, provider_with_config): p = provider_with_config( - recall_prefetch_metadata_fields=["chat_id", "thread_id"], - recall_prefetch_metadata_strict=True, + recall_prefetch_tags=["prefetch-tag"], + recall_prefetch_tags_match="all", ) - p._chat_id = "current-chat" - p._thread_id = "current-thread" p._client.arecall = AsyncMock( return_value=SimpleNamespace( results=[ - SimpleNamespace( - text="Foreign memory", - metadata={"chat_id": "other-chat", "thread_id": "other-thread"}, - ), SimpleNamespace(text="Unscoped memory"), + SimpleNamespace(text="Foreign memory", metadata={"chat_id": "other-chat"}), ] ) ) _run_prefetch(p) - assert p._client.arecall.await_count == 1 - assert p._prefetch_result == "" - assert p.prefetch("test query") == "" - - def test_queue_prefetch_drops_partial_metadata_results(self, provider_with_config): - p = provider_with_config(recall_prefetch_metadata_fields=["chat_id", "thread_id"]) - p._chat_id = "current-chat" - p._thread_id = "current-thread" - p._client.arecall = AsyncMock( - return_value=SimpleNamespace( - results=[ - SimpleNamespace( - text="Scoped memory", - metadata={"chat_id": "current-chat", "thread_id": "current-thread"}, - ), - SimpleNamespace( - text="Partial memory", - metadata={"chat_id": "current-chat"}, - ), - ] - ) - ) - - _run_prefetch(p) - - assert p._prefetch_result == "- Scoped memory" + assert p._prefetch_result.splitlines() == ["- Unscoped memory", "- Foreign memory"] - def test_queue_prefetch_metadata_scope_is_ignored_for_reflect_method(self, provider_with_config): + def test_queue_prefetch_reflect_ignores_prefetch_tags(self, provider_with_config): p = provider_with_config( recall_prefetch_method="reflect", - recall_prefetch_metadata_fields=["chat_id"], - recall_prefetch_metadata_strict=True, + recall_prefetch_tags=["prefetch-tag"], + recall_prefetch_tags_match="all_strict", ) - p._chat_id = "current-chat" p._client.areflect = AsyncMock(return_value=SimpleNamespace(text="Synthesized answer")) _run_prefetch(p) @@ -632,27 +549,6 @@ def test_queue_prefetch_metadata_scope_is_ignored_for_reflect_method(self, provi assert p._prefetch_result == "Synthesized answer" p._client.arecall.assert_not_called() - def test_queue_prefetch_metadata_scope_is_noop_when_active_scope_empty(self, provider_with_config): - p = provider_with_config( - recall_prefetch_metadata_fields=["thread_id"], - recall_prefetch_metadata_strict=True, - ) - p._client.arecall = AsyncMock( - return_value=SimpleNamespace( - results=[ - SimpleNamespace( - text="Foreign memory", - metadata={"thread_id": "other-thread"}, - ), - SimpleNamespace(text="Unscoped memory"), - ] - ) - ) - - _run_prefetch(p) - - assert p._prefetch_result.splitlines() == ["- Foreign memory", "- Unscoped memory"] - # --------------------------------------------------------------------------- # sync_turn tests @@ -786,7 +682,7 @@ def test_schema_has_all_new_fields(self, provider): "mode", "api_url", "api_key", "llm_provider", "llm_api_key", "llm_model", "bank_id", "bank_mission", "bank_retain_mission", "recall_budget", "memory_mode", "recall_prefetch_method", - "recall_prefetch_metadata_fields", "recall_prefetch_metadata_strict", + "recall_prefetch_tags", "recall_prefetch_tags_match", "retain_tags", "retain_source", "retain_user_prefix", "retain_assistant_prefix", "recall_tags", "recall_tags_match", diff --git a/website/docs/user-guide/features/memory-providers.md b/website/docs/user-guide/features/memory-providers.md index afbdac5fca410..e4abb5f0afa5b 100644 --- a/website/docs/user-guide/features/memory-providers.md +++ b/website/docs/user-guide/features/memory-providers.md @@ -365,6 +365,10 @@ The setup wizard installs dependencies automatically and only installs what's ne | `retain_user_prefix` | `User` | Label used before user turns in auto-retained transcripts | | `retain_assistant_prefix` | `Assistant` | Label used before assistant turns in auto-retained transcripts | | `recall_tags` | — | Tags to filter on recall | +| `recall_prefetch_tags` | — | Auto-prefetch only: explicit tags to use instead of generic `recall_tags` | +| `recall_prefetch_tags_match` | `any` | Auto-prefetch only: tag matching mode for `recall_prefetch_tags` | + +`recall_prefetch_tags` applies only to automatic prefetch recall. When unset, auto-prefetch falls back to `recall_tags`. It does not affect the manual `hindsight_recall` tool, and it only matches memories that already carry those tags. See [plugin README](https://github.com/NousResearch/hermes-agent/blob/main/plugins/memory/hindsight/README.md) for the full configuration reference.