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
+22 -5
View File
@@ -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