From 490b3e76b1385c3f446c01750d9b17bf2c571971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Thu, 28 May 2026 18:35:39 +0200 Subject: [PATCH] feat(hindsight): default recall_types to observation only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-recall used to surface every fact type Hindsight had on the session — `world`, `experience`, and `observation`. That triple-ships the same underlying signal in three different framings: observations are the concrete events the user said/did/asked, while world and experience facts are aggregate summaries Hindsight derives from those exact observations. Including all three burns most of `recall_max_tokens` on rephrasings, crowds out events the model actually needs to see, and produces effective duplicates in the prompt — observations themselves are deduplicated by construction so observation-only recall is denser per token and closer to conversational ground truth. Change ------ - Default `_recall_types = ["observation"]` (was `None`, which delegated to server-side "return everything"). - `initialize()` now treats a missing `recall_types` config the same way; also accepts comma-separated strings for parity with `recall_tags`. - An explicit `recall_types=[]` config falls back to the default rather than disabling the filter (would silently widen recall vs. the new default). - Added to `get_config_schema()` so it's discoverable via `hermes config`. Per-call `hindsight_recall` tool invocations are unaffected — they already only forward `types` when the caller passes the argument. Docs / migration ---------------- plugins/memory/hindsight/README.md grows a "Behavior change" callout explaining the why (no-duplicates, information-efficient) and how to restore the legacy broad recall: "recall_types": "observation,world,experience" # or a JSON list in `~/.hermes/hindsight/config.json`. Tests ----- - `test_default_values` updated for the new default. - New cases: explicit list override, CSV string accepted, empty list falls back to default (not "wider than default"). --- plugins/memory/hindsight/README.md | 9 ++++++++ plugins/memory/hindsight/__init__.py | 23 +++++++++++++++++-- .../plugins/memory/test_hindsight_provider.py | 22 ++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/plugins/memory/hindsight/README.md b/plugins/memory/hindsight/README.md index 4c7e0f6be30..587f0baaa3e 100644 --- a/plugins/memory/hindsight/README.md +++ b/plugins/memory/hindsight/README.md @@ -75,8 +75,17 @@ 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_types` | `observation` | Fact types surfaced by auto-recall. Comma-separated string or JSON list. **Default narrowed to `observation` only** (see "Behavior change" below). Set to `observation,world,experience` to also include raw facts. | | `auto_recall` | `true` | Automatically recall memories before each turn | +> **Behavior change — `recall_types` defaults to `observation` only.** +> +> Previously auto-recall returned all three fact types. It now returns only observations. +> +> Per [Hindsight's docs](https://hindsight.vectorize.io/developer/observations), observations are the **consolidated** knowledge layer Hindsight builds on top of raw facts: deduplicated beliefs grounded in evidence, refined as new facts arrive, with proof counts and freshness signals. Raw `world` / `experience` facts are the individual supporting evidence that feeds them. For per-turn context injection, observations are denser per token and avoid feeding the model multiple raw facts that one observation already summarizes. +> +> Restore the broad recall with `"recall_types": "observation,world,experience"` (string or JSON list) in `~/.hermes/hindsight/config.json`. Per-turn `hindsight_recall` tool calls are unaffected — they only filter by `types` when the tool argument is passed explicitly. + ### Retain | Key | Default | Description | diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 1ca362e0089..7b969fea0f5 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -579,7 +579,15 @@ class HindsightMemoryProvider(MemoryProvider): # Recall controls self._auto_recall = True self._recall_max_tokens = 4096 - self._recall_types: list[str] | None = None + # Default to observation-only recall. Observations are Hindsight's + # consolidated knowledge layer — deduplicated, evidence-grounded + # beliefs built from many raw facts, with proof counts and + # freshness signals (see hindsight.vectorize.io/developer/observations). + # Including raw world/experience facts re-ships the supporting + # evidence that observations already summarize, burning the + # `recall_max_tokens` budget. Users can restore the broader + # recall via the `recall_types` config key. + self._recall_types: list[str] = ["observation"] self._recall_prompt_preamble = "" self._recall_max_input_chars = 800 @@ -856,6 +864,7 @@ class HindsightMemoryProvider(MemoryProvider): {"key": "retain_assistant_prefix", "description": "Label used before assistant turns in retained transcripts", "default": "Assistant"}, {"key": "recall_tags", "description": "Tags to filter when searching memories (comma-separated)", "default": ""}, {"key": "recall_tags_match", "description": "Tag matching mode for recall", "default": "any", "choices": ["any", "all", "any_strict", "all_strict"]}, + {"key": "recall_types", "description": "Fact types to surface on auto-recall (comma-separated or list). Defaults to observation-only — observations are Hindsight's consolidated, deduplicated, evidence-grounded knowledge layer; raw world/experience facts are the supporting evidence observations already summarize. Set to e.g. 'observation,world,experience' to also include raw facts.", "default": "observation"}, {"key": "auto_recall", "description": "Automatically recall memories before each turn", "default": True}, {"key": "auto_retain", "description": "Automatically retain conversation turns", "default": True}, {"key": "retain_every_n_turns", "description": "Retain every N turns (1 = every turn)", "default": 1}, @@ -1187,7 +1196,17 @@ class HindsightMemoryProvider(MemoryProvider): # Recall controls self._auto_recall = self._config.get("auto_recall", True) self._recall_max_tokens = int(self._config.get("recall_max_tokens", 4096)) - self._recall_types = self._config.get("recall_types") or None + # Default narrows recall to observation-only; pass an explicit + # `recall_types` list in config.json to broaden (e.g. include + # "world" / "experience") or to disable the filter entirely. + configured_types = self._config.get("recall_types") + if configured_types is None: + self._recall_types = ["observation"] + elif isinstance(configured_types, str): + # Allow comma-separated strings for parity with recall_tags. + self._recall_types = [t.strip() for t in configured_types.split(",") if t.strip()] + else: + 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)) self._retain_async = self._config.get("retain_async", True) diff --git a/tests/plugins/memory/test_hindsight_provider.py b/tests/plugins/memory/test_hindsight_provider.py index fcda46e56b0..72ffe9e6f11 100644 --- a/tests/plugins/memory/test_hindsight_provider.py +++ b/tests/plugins/memory/test_hindsight_provider.py @@ -197,10 +197,32 @@ class TestConfig: assert provider._recall_max_input_chars == 800 assert provider._tags is None assert provider._recall_tags is None + # Default recall narrowed to observation-only; world/experience are + # aggregate facts that often crowd out concrete-event signal during + # auto-recall. Users opt back in via the recall_types config key. + assert provider._recall_types == ["observation"] assert provider._bank_mission == "" assert provider._bank_retain_mission is None assert provider._retain_context == "conversation between Hermes Agent and the User" + def test_recall_types_default_is_observation_only(self, provider): + """Auto-recall must filter to observation by default.""" + assert provider._recall_types == ["observation"] + + def test_recall_types_explicit_list_overrides_default(self, provider_with_config): + p = provider_with_config(recall_types=["world", "experience", "observation"]) + assert p._recall_types == ["world", "experience", "observation"] + + def test_recall_types_csv_string_accepted(self, provider_with_config): + """For parity with recall_tags, comma-separated strings work too.""" + p = provider_with_config(recall_types="observation, world") + assert p._recall_types == ["observation", "world"] + + def test_recall_types_empty_list_falls_back_to_default(self, provider_with_config): + """An empty list shouldn't disable the filter (would be wider than default).""" + p = provider_with_config(recall_types=[]) + assert p._recall_types == ["observation"] + def test_custom_config_values(self, provider_with_config): p = provider_with_config( retain_tags=["tag1", "tag2"],