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
32 changes: 29 additions & 3 deletions plugins/memory/hindsight/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,13 +255,26 @@ def _run_sync(coro, timeout: float = _DEFAULT_TIMEOUT):
}


def _load_config() -> dict:
"""$HERMES_HOME/hindsight/config.json (profile-scoped), else ~/.hindsight/config.json
(legacy, shared), else environment variables."""
def _load_file_config() -> dict | None:
"""Load config from disk only (no env fallback); None when no file parses.

Used at the daemon (re)spawn boundary, where the question is "what does
the on-disk config say *now*" — distinct from :func:`_load_config`, whose
env fallback always returns a dict and would clobber explicitly-set
config in env-only setups.
"""
for path in (get_hermes_home() / "hindsight" / "config.json", Path.home() / ".hindsight" / "config.json"):
# A corrupt (or empty) file falls through to the next source, as before the dedup.
if path.exists() and (data := read_json_or_empty(path)):
return data
return None


def _load_config() -> dict:
"""$HERMES_HOME/hindsight/config.json (profile-scoped), else ~/.hindsight/config.json
(legacy, shared), else environment variables."""
if (file_config := _load_file_config()) is not None:
return file_config
# Mode, bank (the data partition), endpoint and retain shaping are per-profile .env values like
# the key beside them: read through the secret scope so a multiplexed secondary never inherits
# the default profile's bank/mode. Tuning knobs (timeouts, budget) stay process-global.
Expand Down Expand Up @@ -473,6 +486,19 @@ def _new_embedded_client(self):
_ensure_client_dependency()
from hindsight import HindsightEmbedded
HindsightEmbedded.__del__ = lambda self: None
# Client creation is the daemon (re)spawn boundary: the embedded
# manager materializes ~/.hindsight/profiles/<p>.env from these
# kwargs. Re-read config.json here so a long-running gateway that
# recreates the client (idle shutdown / stale-connection retry) uses
# current on-disk settings instead of its initialize()-time snapshot —
# otherwise an edit like an llm_model bump is silently reverted at the
# next respawn. File-gated on purpose: env-only setups keep their
# existing config, and session-shaping fields (banks, recall tuning)
# keep their initialize()-time values for in-session stability.
fresh_config = _load_file_config()
if fresh_config is not None:
self._config = fresh_config
self._llm_base_url = fresh_config.get("llm_base_url", "")
cfg = self._config
llm_provider = _daemon_llm_provider(cfg.get("llm_provider", ""))
logger.debug("Creating HindsightEmbedded client (profile=%s, provider=%s)",
Expand Down
64 changes: 63 additions & 1 deletion tests/plugins/memory/test_hindsight_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ def test_embedded_profile_env_includes_idle_timeout_from_config(self):
assert env["HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT"] == "0"


def test_get_client_passes_idle_timeout_to_hindsight_embedded(self, monkeypatch):
def test_get_client_passes_idle_timeout_to_hindsight_embedded(self, tmp_path, monkeypatch):
captured = {}

class FakeHindsightEmbedded:
Expand All @@ -356,6 +356,13 @@ def __init__(self, **kwargs):

monkeypatch.setitem(sys.modules, "hindsight", SimpleNamespace(HindsightEmbedded=FakeHindsightEmbedded))
monkeypatch.setattr("plugins.memory.hindsight._check_local_runtime", lambda: (True, ""))
# The fake module stands in for the optional hindsight-client dep;
# don't let the lazy-installer try to fetch it.
monkeypatch.setattr("plugins.memory.hindsight._ensure_client_dependency", lambda: None)
# Hermetic against a real host config: client creation re-reads the
# on-disk config when one exists, so point the lookup at an empty tmp.
monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: tmp_path)
monkeypatch.setenv("HOME", str(tmp_path)) # legacy ~/.hindsight fallback

p = HindsightMemoryProvider()
p._mode = "local_embedded"
Expand All @@ -373,6 +380,61 @@ def __init__(self, **kwargs):
assert captured["idle_timeout"] == 0
assert captured["llm_provider"] == "openai"

def test_get_client_rereads_disk_config_at_respawn(self, tmp_path, monkeypatch):
"""Client recreation must pick up config.json edits made after init.

A long-running gateway recreates the embedded client after daemon
idle-shutdown or stale-connection retries; the embedded manager then
re-materializes the profile .env from the client kwargs. Before the
fix it used the initialize()-time snapshot, silently reverting
on-disk edits (e.g. an llm_model bump) at the next respawn.
"""
captured = {}

class FakeHindsightEmbedded:
def __init__(self, **kwargs):
captured.update(kwargs)

monkeypatch.setitem(sys.modules, "hindsight", SimpleNamespace(HindsightEmbedded=FakeHindsightEmbedded))
monkeypatch.setattr("plugins.memory.hindsight._check_local_runtime", lambda: (True, ""))
monkeypatch.setattr("plugins.memory.hindsight._ensure_client_dependency", lambda: None)
monkeypatch.setattr("plugins.memory.hindsight.get_hermes_home", lambda: tmp_path)

config_path = tmp_path / "hindsight" / "config.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(json.dumps({
"mode": "local_embedded",
"profile": "hermes",
"llm_provider": "openai",
"llm_api_key": "test-key",
"llm_model": "old-model",
"idle_timeout": 0,
}))

p = HindsightMemoryProvider()
p._mode = "local_embedded"
p._config = json.loads(config_path.read_text()) # initialize()-time snapshot
p._llm_base_url = ""

# Operator edits config.json while the gateway keeps running.
config_path.write_text(json.dumps({
"mode": "local_embedded",
"profile": "hermes",
"llm_provider": "openai",
"llm_api_key": "test-key",
"llm_model": "new-model",
"llm_base_url": "http://localhost:9001/v1",
"idle_timeout": 0,
}))

# Respawn boundary: stale-connection retry recreates the client.
p._client = None
p._get_client()

assert captured["llm_model"] == "new-model"
assert captured["llm_base_url"] == "http://localhost:9001/v1"
assert p._config["llm_model"] == "new-model"


class TestPostSetup:
def test_setup_cancel_at_mode_picker_writes_nothing(self, tmp_path, monkeypatch):
Expand Down