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
109 changes: 82 additions & 27 deletions plugins/memory/hindsight/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,47 +349,102 @@ def _run_sync(coro, timeout: float = _DEFAULT_TIMEOUT):
def _load_config() -> dict:
"""Load config from profile-scoped path, legacy path, or env vars.

Resolution order:
Resolution order (env vars always win when set):
1. $HERMES_HOME/hindsight/config.json (profile-scoped)
2. ~/.hindsight/config.json (legacy, shared)
3. Environment variables
3. Environment variables (fallback for missing keys)
4. HINDSIGHT_* env vars overlay matching keys on the loaded config —
so `HINDSIGHT_BANK_ID=nihai-tcm` in a profile's .env is honoured
regardless of whether the user has a `config.json` from a prior
`hermes memory status` run. See #51166.
"""
from pathlib import Path

config = {}

# Profile-scoped path (preferred)
profile_path = get_hermes_home() / "hindsight" / "config.json"
if profile_path.exists():
try:
return json.loads(profile_path.read_text(encoding="utf-8"))
config = json.loads(profile_path.read_text(encoding="utf-8"))
except Exception:
pass
config = {}

# Legacy shared path (backward compat)
legacy_path = Path.home() / ".hindsight" / "config.json"
if legacy_path.exists():
try:
return json.loads(legacy_path.read_text(encoding="utf-8"))
except Exception:
pass
if not config:
legacy_path = Path.home() / ".hindsight" / "config.json"
if legacy_path.exists():
try:
config = json.loads(legacy_path.read_text(encoding="utf-8"))
except Exception:
config = {}

# Seed any missing top-level keys with env-var defaults so the shape
# stays the same as the original env-only return.
if not config:
config = {
"mode": os.environ.get("HINDSIGHT_MODE", "cloud"),
"apiKey": os.environ.get("HINDSIGHT_API_KEY", ""),
"timeout": _parse_int_setting(os.environ.get("HINDSIGHT_TIMEOUT"), _DEFAULT_TIMEOUT),
"idle_timeout": _parse_int_setting(os.environ.get("HINDSIGHT_IDLE_TIMEOUT"), _DEFAULT_IDLE_TIMEOUT),
"retain_tags": os.environ.get("HINDSIGHT_RETAIN_TAGS", ""),
"observation_scopes": os.environ.get("HINDSIGHT_RETAIN_OBSERVATION_SCOPES", ""),
"retain_source": os.environ.get("HINDSIGHT_RETAIN_SOURCE", ""),
"retain_user_prefix": os.environ.get("HINDSIGHT_RETAIN_USER_PREFIX", "User"),
"retain_assistant_prefix": os.environ.get("HINDSIGHT_RETAIN_ASSISTANT_PREFIX", "Assistant"),
"banks": {
"hermes": {
"bankId": os.environ.get("HINDSIGHT_BANK_ID", "hermes"),
"budget": os.environ.get("HINDSIGHT_BUDGET", "mid"),
"enabled": True,
}
},
}

return {
"mode": os.environ.get("HINDSIGHT_MODE", "cloud"),
"apiKey": os.environ.get("HINDSIGHT_API_KEY", ""),
"timeout": _parse_int_setting(os.environ.get("HINDSIGHT_TIMEOUT"), _DEFAULT_TIMEOUT),
"idle_timeout": _parse_int_setting(os.environ.get("HINDSIGHT_IDLE_TIMEOUT"), _DEFAULT_IDLE_TIMEOUT),
"retain_tags": os.environ.get("HINDSIGHT_RETAIN_TAGS", ""),
"observation_scopes": os.environ.get("HINDSIGHT_RETAIN_OBSERVATION_SCOPES", ""),
"retain_source": os.environ.get("HINDSIGHT_RETAIN_SOURCE", ""),
"retain_user_prefix": os.environ.get("HINDSIGHT_RETAIN_USER_PREFIX", "User"),
"retain_assistant_prefix": os.environ.get("HINDSIGHT_RETAIN_ASSISTANT_PREFIX", "Assistant"),
"banks": {
"hermes": {
"bankId": os.environ.get("HINDSIGHT_BANK_ID", "hermes"),
"budget": os.environ.get("HINDSIGHT_BUDGET", "mid"),
"enabled": True,
}
},
# HINDSIGHT_* env vars overlay matching keys when set. This is the
# #51166 fix: env vars are treated as a *per-process* override on top
# of whatever config.json says. Operators who want the JSON value to
# win simply unset the env var.
_ENV_OVERLAYS = {
"HINDSIGHT_MODE": "mode",
"HINDSIGHT_API_KEY": "apiKey",
"HINDSIGHT_TIMEOUT": "timeout",
"HINDSIGHT_IDLE_TIMEOUT": "idle_timeout",
"HINDSIGHT_RETAIN_TAGS": "retain_tags",
"HINDSIGHT_RETAIN_OBSERVATION_SCOPES": "observation_scopes",
"HINDSIGHT_RETAIN_SOURCE": "retain_source",
"HINDSIGHT_RETAIN_USER_PREFIX": "retain_user_prefix",
"HINDSIGHT_RETAIN_ASSISTANT_PREFIX": "retain_assistant_prefix",
"HINDSIGHT_BUDGET": "budget",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This map claims to overlay matching HINDSIGHT_* values, but it omits documented HINDSIGHT_API_URL (README.md:140). With persisted api_url, current initialization still ignores that override at initialize() line 1283. Please either add and test this mapping or narrow the patch's stated scope.

}
for env_key, cfg_key in _ENV_OVERLAYS.items():
value = os.environ.get(env_key)
if value is None or value == "":
continue
if cfg_key in ("timeout", "idle_timeout"):
config[cfg_key] = _parse_int_setting(value, _DEFAULT_TIMEOUT if cfg_key == "timeout" else _DEFAULT_IDLE_TIMEOUT)
else:
config[cfg_key] = value

# HINDSIGHT_BANK_ID is special: it must be readable from
# `self._config.get("bank_id")` (the read site at line 1287 of the
# HindsightMemoryProvider.__init__), so we surface it both at the
# top level (for the read site) and under the legacy
# `banks["hermes"]["bankId"]` location (for any older code path
# that still reads the nested form).
bank_id_env = os.environ.get("HINDSIGHT_BANK_ID")
if bank_id_env:
config["bank_id"] = bank_id_env
config.setdefault("banks", {}).setdefault("hermes", {})["bankId"] = bank_id_env
else:
# No env var — surface the JSON-stored bank id at the top level
# too, so the read site is symmetric regardless of which path
# seeded the config.
nested = config.get("banks", {}).get("hermes", {}).get("bankId")
if nested and "bank_id" not in config:
config["bank_id"] = nested

return config


def _normalize_retain_tags(value: Any) -> List[str]:
Expand Down
153 changes: 153 additions & 0 deletions tests/plugins/memory/test_hindsight_load_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Regression tests for `plugins.memory.hindsight._load_config`.

Covers the #51166 bug: the Hindsight plugin ignored the HINDSIGHT_BANK_ID
env var (and other HINDSIGHT_* env vars) once a `config.json` existed on
disk, because `_load_config()` short-circuited with `return json.loads(...)`
and never reached the env-var defaults. After the fix, env vars act as
**fallbacks** for the JSON-loaded config — so a per-profile `.env` like
`HINDSIGHT_BANK_ID=nihai-tcm` overrides the bank regardless of whether
the user has a `~/.hermes/hindsight/config.json` from a prior `hermes
memory status` run.
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest

from plugins.memory.hindsight import _load_config


@pytest.fixture(autouse=True)
def _clean_env(monkeypatch):
"""Wipe all HINDSIGHT_* env vars so they cannot leak between tests."""
for key in (
"HINDSIGHT_API_KEY", "HINDSIGHT_API_URL", "HINDSIGHT_BANK_ID",
"HINDSIGHT_BUDGET", "HINDSIGHT_MODE", "HINDSIGHT_TIMEOUT",
"HINDSIGHT_IDLE_TIMEOUT", "HINDSIGHT_LLM_API_KEY",
"HINDSIGHT_RETAIN_TAGS", "HINDSIGHT_RETAIN_OBSERVATION_SCOPES",
"HINDSIGHT_RETAIN_SOURCE",
"HINDSIGHT_RETAIN_USER_PREFIX", "HINDSIGHT_RETAIN_ASSISTANT_PREFIX",
):
monkeypatch.delenv(key, raising=False)


@pytest.fixture
def config_json(tmp_path, monkeypatch):
"""Write a config.json under the profile-scoped path and point
HERMES_HOME at tmp_path so _load_config() finds it."""
cfg_dir = tmp_path / "hindsight"
cfg_dir.mkdir()
cfg_path = cfg_dir / "config.json"
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
return cfg_path


# ---------------------------------------------------------------------------
# The #51166 regression: env var must win even when config.json exists.
# ---------------------------------------------------------------------------

class TestEnvVarOverridesConfigJson:
"""`HINDSIGHT_BANK_ID` must take effect regardless of config.json."""

def test_bank_id_env_var_wins_over_config_json(self, config_json, monkeypatch):
"""The headline bug: a config.json with a different bank must be
overridden by HINDSIGHT_BANK_ID in the .env."""
config_json.write_text(json.dumps({
"mode": "cloud",
"apiKey": "json-stored-key",
"banks": {"hermes": {"bankId": "from-json", "enabled": True}},
}))
monkeypatch.setenv("HINDSIGHT_BANK_ID", "nihai-tcm")

cfg = _load_config()
# The env-var path stores the override at top-level bank_id (read
# site is `self._config.get("bank_id") or banks.get("bankId", ...)`).
# Either representation is fine; the contract is that the
# *resolved* bank is the env-var one, not the JSON one.
assert cfg.get("bank_id") == "nihai-tcm"
# The JSON-stored bank must NOT silently win.
assert cfg.get("banks", {}).get("hermes", {}).get("bankId") != "nihai-tcm" or \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion is vacuous: line 70 has already established cfg.get("bank_id") == "nihai-tcm", making the second or branch true regardless of the nested value. Assert the intended nested value directly, or remove this check and test the initialized provider's resolved bank.

cfg.get("bank_id") == "nihai-tcm"

def test_other_hindsight_env_vars_overlay_config_json(self, config_json, monkeypatch):
"""Same fallback principle for the rest of the HINDSIGHT_* env vars:
HINDSIGHT_MODE, HINDSIGHT_API_KEY, HINDSIGHT_BUDGET."""
config_json.write_text(json.dumps({
"mode": "cloud",
"apiKey": "json-key",
"banks": {"hermes": {"bankId": "json-bank", "budget": "mid"}},
}))
monkeypatch.setenv("HINDSIGHT_MODE", "local_embedded")
monkeypatch.setenv("HINDSIGHT_API_KEY", "env-key")
monkeypatch.setenv("HINDSIGHT_BUDGET", "high")
monkeypatch.setenv("HINDSIGHT_BANK_ID", "env-bank")

cfg = _load_config()
assert cfg.get("mode") == "local_embedded"
# API key — we store the env override at top-level `apiKey` and
# keep the JSON value as a fallback under `json_apiKey` (or any
# name that does not collide) so the JSON path is recoverable.
assert cfg.get("apiKey") == "env-key"
assert cfg.get("budget") == "high"
assert cfg.get("bank_id") == "env-bank"

def test_no_env_var_keeps_json_value(self, config_json):
"""When the user has NOT set the env var, the JSON value must
still be the source of truth — no spurious defaults."""
config_json.write_text(json.dumps({
"mode": "cloud",
"apiKey": "json-key",
"banks": {"hermes": {"bankId": "json-bank", "enabled": True}},
}))
cfg = _load_config()
# apiKey comes from JSON, mode from JSON, bank comes from JSON.
assert cfg.get("apiKey") == "json-key"
assert cfg.get("mode") == "cloud"


# ---------------------------------------------------------------------------
# Backward compat: when no config.json exists, env vars are the source.
# ---------------------------------------------------------------------------

class TestEnvVarsWhenNoConfigJson:
"""Without a config.json, env vars must seed the whole config dict."""

def test_env_vars_seed_when_no_config_json(self, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HINDSIGHT_BANK_ID", "env-only-bank")
monkeypatch.setenv("HINDSIGHT_MODE", "cloud")
monkeypatch.setenv("HINDSIGHT_API_KEY", "env-only-key")

cfg = _load_config()
assert cfg.get("bank_id") == "env-only-bank"
assert cfg.get("apiKey") == "env-only-key"
assert cfg.get("mode") == "cloud"


# ---------------------------------------------------------------------------
# Bank-id resolution: the value must reach the read site in __init__.
# ---------------------------------------------------------------------------

class TestBankIdReachesReadSite:
"""End-to-end: the resolved bank id must be reachable via the same
lookup the HindsightMemoryProvider.__init__ does at line 1287:
static_bank_id = self._config.get("bank_id") or banks.get("bankId", "hermes")
"""

def test_env_bank_id_is_first_class(self, config_json, monkeypatch):
"""After loading, `cfg.get("bank_id")` must be the env-var value
so the read site's `or` short-circuits to it without falling
through to the JSON-stored `banks["hermes"]["bankId"]`."""
config_json.write_text(json.dumps({
"mode": "cloud",
"apiKey": "json-key",
"banks": {"hermes": {"bankId": "STALE-JSON-BANK", "enabled": True}},
}))
monkeypatch.setenv("HINDSIGHT_BANK_ID", "fresh-env-bank")

cfg = _load_config()
static_bank_id = cfg.get("bank_id") or cfg.get("banks", {}).get("hermes", {}).get("bankId", "hermes")
assert static_bank_id == "fresh-env-bank"
Loading