diff --git a/plugins/memory/hindsight/README.md b/plugins/memory/hindsight/README.md index 4c7e0f6be30e..898c1bb4f668 100644 --- a/plugins/memory/hindsight/README.md +++ b/plugins/memory/hindsight/README.md @@ -75,8 +75,16 @@ Config file: `~/.hermes/hindsight/config.json` | `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_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 | Key | Default | Description | diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index bc82bc40fb5d..498df10bcd18 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -46,6 +46,7 @@ _MIN_CLIENT_VERSION = "0.4.22" _DEFAULT_TIMEOUT = 120 # seconds — cloud API can take 30-40s per request _VALID_BUDGETS = {"low", "mid", "high"} +_VALID_TAG_MATCH_MODES = {"any", "all", "any_strict", "all_strict"} _PROVIDER_DEFAULT_MODELS = { "openai": "gpt-4o-mini", "anthropic": "claude-haiku-4-5", @@ -217,7 +218,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 [] @@ -254,6 +255,12 @@ def _normalize_retain_tags(value: Any) -> List[str]: 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: """Return current UTC timestamp in ISO-8601 with milliseconds and Z suffix.""" return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z") @@ -424,6 +431,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 @@ -678,6 +687,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_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"}, @@ -834,13 +845,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() @@ -877,10 +892,10 @@ 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, 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._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 @@ -991,9 +1006,15 @@ 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)", @@ -1075,8 +1096,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 5f1290b2f16a..6198246b2ca6 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -18,7 +18,7 @@ REFLECT_SCHEMA, RETAIN_SCHEMA, _load_config, - _normalize_retain_tags, + _normalize_tags, _resolve_bank_id_template, _sanitize_bank_segment, ) @@ -75,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 []) @@ -136,16 +142,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"] + assert _normalize_tags(value) == ["agent:fakeassistantname", "source_system:hermes-agent"] # --------------------------------------------------------------------------- @@ -204,6 +210,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_tags=["prefetch-tag"], + recall_prefetch_tags_match="all_strict", recall_tags=["recall-tag"], recall_tags_match="all", auto_retain=False, @@ -222,6 +230,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_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 @@ -251,6 +261,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_tag_config(self, provider): + assert provider._recall_prefetch_tags is None + assert provider._recall_prefetch_tags_match == "any" + class TestPostSetup: def test_local_embedded_setup_materializes_profile_env(self, tmp_path, monkeypatch): @@ -446,6 +460,29 @@ def test_recall_error_handling(self, provider): )) assert "error" in result + def test_recall_tool_ignores_prefetch_tag_config(self, provider_with_config): + p = provider_with_config( + recall_tags=["manual-tag"], + recall_tags_match="all", + recall_prefetch_tags=["prefetch-tag"], + recall_prefetch_tags_match="all_strict", + ) + p._client.arecall = AsyncMock( + return_value=SimpleNamespace( + results=[ + SimpleNamespace( + text="Foreign memory", + ) + ] + ) + ) + + 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" + # --------------------------------------------------------------------------- # Prefetch tests @@ -508,9 +545,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 @@ -518,6 +553,83 @@ 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) + + 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_uses_prefetch_tags_when_configured(self, provider_with_config): + p = provider_with_config( + 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._client.arecall = AsyncMock( + return_value=SimpleNamespace( + results=[ + SimpleNamespace(text="Tagged memory"), + ] + ) + ) + + _run_prefetch(p) + + 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_with_prefetch_tags_does_not_post_filter_results(self, provider_with_config): + p = provider_with_config( + recall_prefetch_tags=["prefetch-tag"], + recall_prefetch_tags_match="all", + ) + p._client.arecall = AsyncMock( + return_value=SimpleNamespace( + results=[ + SimpleNamespace(text="Unscoped memory"), + SimpleNamespace(text="Foreign memory", metadata={"chat_id": "other-chat"}), + ] + ) + ) + + _run_prefetch(p) + + assert p._prefetch_result.splitlines() == ["- Unscoped memory", "- Foreign memory"] + + def test_queue_prefetch_reflect_ignores_prefetch_tags(self, provider_with_config): + p = provider_with_config( + recall_prefetch_method="reflect", + recall_prefetch_tags=["prefetch-tag"], + recall_prefetch_tags_match="all_strict", + ) + 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() + # --------------------------------------------------------------------------- # sync_turn tests @@ -786,6 +898,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_id_template", "bank_mission", "bank_retain_mission", "recall_budget", "memory_mode", "recall_prefetch_method", + "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 afbdac5fca41..e4abb5f0afa5 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.