Skip to content
Closed
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
8 changes: 8 additions & 0 deletions plugins/memory/hindsight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
43 changes: 32 additions & 11 deletions plugins/memory/hindsight/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 []
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"},
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)",
Expand Down Expand Up @@ -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:
Expand Down
129 changes: 121 additions & 8 deletions tests/plugins/memory/test_hindsight_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
REFLECT_SCHEMA,
RETAIN_SCHEMA,
_load_config,
_normalize_retain_tags,
_normalize_tags,
_resolve_bank_id_template,
_sanitize_bank_segment,
)
Expand Down Expand Up @@ -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 [])
Expand Down Expand Up @@ -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"]


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -508,16 +545,91 @@ 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
assert call_kwargs["tags"] == ["t1"]
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
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions website/docs/user-guide/features/memory-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down