From a48dde5316e286e22b82574606134ed6287da604 Mon Sep 17 00:00:00 2001 From: John Paul Soliva Date: Sat, 12 Sep 2026 15:11:28 +0900 Subject: [PATCH] fix(memory/hindsight): resolve retain shaping through the profile scope, never os.environ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _load_config() reads the Hindsight bank, mode and retain tags through the profile secret scope, but _apply_retain_settings() then discarded that answer and re-read os.environ whenever the config value was falsy: return cfg.get(key) or os.environ.get(env_var, default) Under gateway.multiplex_profiles os.environ holds the DEFAULT profile's .env, so a secondary profile's scoped miss came back as the default profile's retain tags, observation scopes, source and speaker prefixes — the fallback-after-miss shape gateway/AGENTS.md forbids. Tags are Hindsight's retrieval partition and metadata.source is opt-in by design, so the secondary's memories were both mislabelled and selectable by the default profile's tag filters. Both halves now go through _scoped_setting(), which resolves the value with get_secret() and falls back to the provider's OWN default — a miss is a miss, the same rule embedded.py already applies to the daemon's key and base URL. The three raw reads left inside _load_config() (retain_source, retain_user_prefix, retain_assistant_prefix), directly under the comment declaring them per-profile, go through it too. Single-profile deployments are unchanged: with no scope installed get_secret() still reads the process env, where the value IS this profile's own. Fixes #108865 --- plugins/memory/hindsight/__init__.py | 27 +++++++-- .../test_multiplex_memory_identity_scope.py | 58 +++++++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/plugins/memory/hindsight/__init__.py b/plugins/memory/hindsight/__init__.py index 6c52994765..06bf1a0687 100644 --- a/plugins/memory/hindsight/__init__.py +++ b/plugins/memory/hindsight/__init__.py @@ -25,7 +25,7 @@ from pathlib import Path from typing import Any, Callable, Dict, List, Optional from agent.memory_provider import MemoryProvider, RecallStatus, spawn_context_thread -from agent.secret_scope import get_secret +from agent.secret_scope import UnscopedSecretError, get_secret from hermes_cli.config import cfg_get from hermes_constants import get_hermes_home from hermes_time import now as _hermes_now @@ -63,6 +63,21 @@ def _ensure_client_dependency() -> None: raise ImportError(str(exc)) from exc +def _scoped_setting(name: str, default: str = "") -> str: + """Profile-scoped read of a per-profile Hindsight setting, with the provider's own default on a miss. + + Under ``gateway.multiplex_profiles`` ``os.environ`` holds the DEFAULT profile's ``.env``, so a miss + is a miss — never ``os.environ`` (same rule as the daemon's key and base URL in ``embedded.py``). + Single-profile deployments are unchanged: with no scope installed ``get_secret`` still reads the + process env, where the value IS this profile's own. + """ + try: + value = get_secret(name, default) + except UnscopedSecretError: + return default + return default if value is None else value + + def _cloud_api_key(config: dict) -> str: return config.get("apiKey") or config.get("api_key") or get_secret("HINDSIGHT_API_KEY", "") @@ -248,9 +263,9 @@ def _load_config() -> dict: "idle_timeout": _parse_int_setting(os.environ.get("HINDSIGHT_IDLE_TIMEOUT"), _DEFAULT_IDLE_TIMEOUT), "retain_tags": get_secret("HINDSIGHT_RETAIN_TAGS", "") or "", "observation_scopes": get_secret("HINDSIGHT_RETAIN_OBSERVATION_SCOPES", "") or "", - "retain_source": os.environ.get("HINDSIGHT_RETAIN_SOURCE", _DEFAULT_RETAIN_SOURCE), - "retain_user_prefix": os.environ.get("HINDSIGHT_RETAIN_USER_PREFIX", "User"), - "retain_assistant_prefix": os.environ.get("HINDSIGHT_RETAIN_ASSISTANT_PREFIX", "Assistant"), + "retain_source": _scoped_setting("HINDSIGHT_RETAIN_SOURCE", _DEFAULT_RETAIN_SOURCE), + "retain_user_prefix": _scoped_setting("HINDSIGHT_RETAIN_USER_PREFIX", "User"), + "retain_assistant_prefix": _scoped_setting("HINDSIGHT_RETAIN_ASSISTANT_PREFIX", "Assistant"), "banks": {"hermes": {"bankId": get_secret("HINDSIGHT_BANK_ID", "") or "hermes", "budget": os.environ.get("HINDSIGHT_BUDGET", "mid"), "enabled": True}}, } @@ -724,7 +739,9 @@ class HindsightMemoryProvider(MemoryProvider): def _apply_retain_settings(self, cfg: dict) -> None: def _cfg_or_env(key: str, env_var: str, default: str = "") -> Any: - return cfg.get(key) or os.environ.get(env_var, default) + # The env half is the same per-profile value ``_load_config`` resolves through the scope; + # a raw read here handed a multiplexed secondary the DEFAULT profile's retain shaping back. + return cfg.get(key) or _scoped_setting(env_var, default) self._retain_tags = _normalize_retain_tags(_cfg_or_env("retain_tags", "HINDSIGHT_RETAIN_TAGS")) self._tags = self._retain_tags or None diff --git a/tests/plugins/memory/test_multiplex_memory_identity_scope.py b/tests/plugins/memory/test_multiplex_memory_identity_scope.py index 49f5e854f6..b9a83b8350 100644 --- a/tests/plugins/memory/test_multiplex_memory_identity_scope.py +++ b/tests/plugins/memory/test_multiplex_memory_identity_scope.py @@ -19,6 +19,9 @@ _DEFAULT_ENV = { "OPENVIKING_API_KEY": "ov-default", "OPENVIKING_ACCOUNT": "acct-default", "OPENVIKING_USER": "user-default", "OPENVIKING_AGENT": "agent-default", "OPENVIKING_ENDPOINT": "http://ov.default", "HINDSIGHT_BANK_ID": "bank-default", "HINDSIGHT_MODE": "local_external", "HINDSIGHT_API_URL": "http://hs.default", + "HINDSIGHT_RETAIN_TAGS": "tag-default", "HINDSIGHT_RETAIN_OBSERVATION_SCOPES": "per_tag", + "HINDSIGHT_RETAIN_SOURCE": "source-default", "HINDSIGHT_RETAIN_USER_PREFIX": "UserDefault", + "HINDSIGHT_RETAIN_ASSISTANT_PREFIX": "AssistantDefault", "HERMES_HONCHO_HOST": "host-default", "HONCHO_BASE_URL": "https://honcho.default", "OPENAI_API_KEY": "sk-default", "OPENAI_BASE_URL": "https://openai.default/v1", } @@ -94,3 +97,58 @@ def test_mem0_oss_llm_never_borrows_default_profile_openai_key(secondary_profile secret_scope.reset_secret_scope(token) assert llm.client.api_key == "sk-b" assert str(llm.client.base_url).startswith("https://openai.b/v1") + + +def test_hindsight_retain_shaping_is_not_re_read_from_the_default_profile_environ(secondary_profile): + """The provider must retain with ITS OWN shaping, not the default profile's. + + ``_load_config`` resolves retain shaping through the secret scope, but the values it produces + pass through ``_apply_retain_settings``, whose ``cfg_or_env`` half re-read ``os.environ`` — so + every scoped miss came back as the default profile's tag, scope, source and speaker prefixes. + ``metadata.source`` is opt-in by AGENTS.md, and tags are the retrieval partition, so a secondary + profile's memories were both mislabelled and selectable by the default profile's tag filters. + """ + import plugins.memory.hindsight as hindsight + from plugins.memory.hindsight.settings import _DEFAULT_RETAIN_SOURCE + + cfg = hindsight._load_config() + assert (cfg["retain_source"], cfg["retain_user_prefix"], cfg["retain_assistant_prefix"]) == ( + _DEFAULT_RETAIN_SOURCE, "User", "Assistant") + + provider = hindsight.HindsightMemoryProvider() + provider._apply_retain_settings(cfg) + assert provider._retain_tags == [] + assert provider._observation_scopes is None + assert provider._retain_source == _DEFAULT_RETAIN_SOURCE + assert (provider._retain_user_prefix, provider._retain_assistant_prefix) == ("User", "Assistant") + + # A config.json install never reaches ``_load_config``'s scoped read at all, so the fallback + # inside ``_apply_retain_settings`` is the only gate for it. + provider._apply_retain_settings({}) + assert provider._retain_tags == [] + assert provider._retain_source == _DEFAULT_RETAIN_SOURCE + + # The profile's OWN scoped values still win — this is isolation, not a blindfold. + token = secret_scope.set_secret_scope({"HINDSIGHT_RETAIN_TAGS": "tag-b", "HINDSIGHT_RETAIN_SOURCE": "source-b"}) + try: + provider._apply_retain_settings({}) + finally: + secret_scope.reset_secret_scope(token) + assert provider._retain_tags == ["tag-b"] + assert provider._retain_source == "source-b" + + +def test_hindsight_retain_shaping_still_reads_the_process_env_for_a_single_profile(monkeypatch, tmp_path): + """No multiplexing: the process env IS this profile's own .env, so it must keep being read.""" + import plugins.memory.hindsight as hindsight + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("HINDSIGHT_RETAIN_TAGS", "solo-tag") + monkeypatch.setenv("HINDSIGHT_RETAIN_SOURCE", "solo-source") + monkeypatch.setenv("HINDSIGHT_RETAIN_USER_PREFIX", "Operator") + + provider = hindsight.HindsightMemoryProvider() + provider._apply_retain_settings({}) + assert provider._retain_tags == ["solo-tag"] + assert provider._retain_source == "solo-source" + assert provider._retain_user_prefix == "Operator"