fix(memory/hindsight): resolve retain shaping through the profile scope, never os.environ

_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
This commit is contained in:
John Paul Soliva
2026-09-12 15:11:28 +09:00
committed by Teknium
parent 122ad719b5
commit a48dde5316
2 changed files with 80 additions and 5 deletions
@@ -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"