feat(desktop): schema-driven memory-provider config surface

Make the desktop memory settings dynamic instead of hardcoded per
provider. The dropdown is now populated from discover_memory_providers()
(bundled + user-installed + pip) rather than a static enum, and each
provider's config panel is derived from its own get_config_schema() —
the same declaration `hermes memory setup` uses — so adding or porting a
provider is pure declaration with no bespoke UI, conditional, or
endpoint.

- memory_providers.py: reworked from a hand-written Hindsight registry
  into a pure adapter (describe_provider + coerce_value) that normalizes
  a provider's raw schema into typed fields — secret(+env), select,
  boolean, typed text — carrying `when` conditionals, url, and required.
- MemoryProvider ABC: add optional read_current_config() (default {}),
  the read-back mirror of save_config(). Ported mem0, hindsight, honcho,
  holographic; the rest fall back to schema defaults safely.
- web_server GET/PUT /api/memory/providers/{name}/config now load the
  live provider, derive its schema, write non-secrets via the provider's
  own save_config() (each keeps its native storage), persist secrets to
  the env store, and `when`-gate validation so hidden fields aren't
  required or written. Secrets stay write-only (is_set only).
- ProviderConfigPanel: `when`-conditional visibility (handles Hindsight's
  mode-gated duplicate keys), boolean toggle, and credential url links.
  Dropdown driven by getMemoryStatus(); hardcoded enum removed.

Tests assert the mapping contract and endpoint behavior (schema
derivation, save-via-save_config, secret-never-returned, when-gating,
select rejection) against real bundled providers rather than a snapshot
of a hardcoded list.
This commit is contained in:
Brooklyn Nicholson 2026-06-18 17:16:13 -05:00
parent 03d9a95a74
commit e48d18c8e2
16 changed files with 770 additions and 291 deletions

View file

@ -1,46 +1,129 @@
"""Tests for the declarative memory-provider registry."""
"""Tests for the schema-driven memory-provider config adapter.
These assert the *mapping contract* (how a provider's declared
``get_config_schema()`` becomes the normalized desktop field shape) and the
write-time coercion — not a snapshot of any particular provider's fields, which
are free to change.
"""
import pytest
from hermes_cli.memory_providers import (
KIND_BOOLEAN,
KIND_SECRET,
KIND_SELECT,
get_memory_provider,
KIND_TEXT,
VALUE_BOOL,
VALUE_INT,
coerce_value,
describe_provider,
)
def test_hindsight_is_declared():
provider = get_memory_provider("hindsight")
assert provider is not None
assert provider.label == "Hindsight"
assert {field.key for field in provider.fields} == {
"mode",
"api_key",
"api_url",
"bank_id",
"recall_budget",
}
def _by_key(provider):
return {f.key: f for f in provider.fields}
def test_hindsight_mode_gating_is_expressed_as_select_options():
provider = get_memory_provider("hindsight")
assert provider is not None
def test_secret_field_maps_to_secret_kind_bound_to_env():
provider = describe_provider(
"x",
[{"key": "api_key", "secret": True, "env_var": "X_API_KEY", "url": "https://x"}],
)
field = _by_key(provider)["api_key"]
mode = next(field for field in provider.fields if field.key == "mode")
assert mode.kind == KIND_SELECT
assert mode.allowed_values() == {"cloud", "local_external"}
# local_embedded is intentionally unsupported on desktop.
assert "local_embedded" not in mode.allowed_values()
assert field.kind == KIND_SECRET
assert field.is_secret is True
assert field.env_key == "X_API_KEY"
assert field.url == "https://x"
def test_api_key_is_a_secret_bound_to_env():
provider = get_memory_provider("hindsight")
assert provider is not None
def test_choices_map_to_select_options():
provider = describe_provider(
"x", [{"key": "mode", "default": "cloud", "choices": ["cloud", "local"]}]
)
field = _by_key(provider)["mode"]
api_key = next(field for field in provider.fields if field.key == "api_key")
assert api_key.kind == KIND_SECRET
assert api_key.is_secret is True
assert api_key.env_key == "HINDSIGHT_API_KEY"
assert field.kind == KIND_SELECT
assert field.allowed_values() == {"cloud", "local"}
def test_unknown_provider_is_none():
assert get_memory_provider("builtin") is None
def test_bool_default_maps_to_boolean_kind():
provider = describe_provider("x", [{"key": "auto", "default": True}])
field = _by_key(provider)["auto"]
assert field.kind == KIND_BOOLEAN
assert field.value_type == VALUE_BOOL
assert field.default == "true"
def test_int_default_maps_to_text_with_int_value_type():
provider = describe_provider("x", [{"key": "tokens", "default": 4096}])
field = _by_key(provider)["tokens"]
assert field.kind == KIND_TEXT
assert field.value_type == VALUE_INT
assert field.default == "4096"
def test_when_clause_is_carried_through():
provider = describe_provider(
"x",
[
{"key": "mode", "default": "cloud", "choices": ["cloud", "local"]},
{"key": "api_url", "default": "u", "when": {"mode": "cloud"}},
],
)
api_url = _by_key(provider)["api_url"]
assert api_url.when == (("mode", "cloud"),)
assert api_url.when_matches({"mode": "cloud"}) is True
assert api_url.when_matches({"mode": "local"}) is False
def test_every_field_has_a_known_kind():
# Invariant: the adapter never emits a field the renderer can't handle.
provider = describe_provider(
"x",
[
{"key": "a", "secret": True, "env_var": "A"},
{"key": "b", "choices": ["1", "2"]},
{"key": "c", "default": True},
{"key": "d", "default": "text"},
],
)
known = {KIND_TEXT, KIND_SELECT, KIND_SECRET, KIND_BOOLEAN}
assert provider.fields # non-empty
assert all(f.kind in known for f in provider.fields)
def test_malformed_entries_are_skipped_not_fatal():
provider = describe_provider("x", ["nope", {}, {"key": ""}, {"key": "ok"}])
assert _by_key(provider).keys() == {"ok"}
def test_coerce_rejects_value_outside_select_options():
provider = describe_provider("x", [{"key": "mode", "choices": ["cloud", "local"]}])
field = _by_key(provider)["mode"]
with pytest.raises(ValueError):
coerce_value(field, "bogus")
def test_coerce_casts_bool_and_int_to_native_types():
provider = describe_provider(
"x", [{"key": "auto", "default": True}, {"key": "tokens", "default": 4096}]
)
fields = _by_key(provider)
assert coerce_value(fields["auto"], "true") is True
assert coerce_value(fields["auto"], "off") is False
assert coerce_value(fields["tokens"], "8000") == 8000
def test_coerce_empty_falls_back_to_default():
provider = describe_provider("x", [{"key": "name", "default": "hermes"}])
field = _by_key(provider)["name"]
assert coerce_value(field, "") == "hermes"

View file

@ -268,70 +268,83 @@ class TestWebServerEndpoints:
def _provider_field_map(payload):
return {field["key"]: field for field in payload["fields"]}
def test_get_memory_provider_config_returns_safe_defaults(self):
resp = self.client.get("/api/memory/providers/hindsight/config")
# The desktop memory-provider config surface is schema-driven: the endpoint
# derives fields from each provider's live get_config_schema(). These tests
# assert that contract against a real bundled provider (mem0 — small, stable
# schema) and Hindsight's when-gating, not a snapshot of a hardcoded list.
def test_get_memory_provider_config_derives_fields_from_schema(self):
resp = self.client.get("/api/memory/providers/mem0/config")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "hindsight"
assert data["label"] == "Hindsight"
assert data["name"] == "mem0"
fields = self._provider_field_map(data)
assert fields["mode"]["kind"] == "select"
assert fields["mode"]["value"] == "cloud"
assert {opt["value"] for opt in fields["mode"]["options"]} == {"cloud", "local_external"}
assert fields["api_url"]["value"] == "https://api.hindsight.vectorize.io"
assert fields["bank_id"]["value"] == "hermes"
assert fields["recall_budget"]["value"] == "mid"
# Declared fields surface generically with their mapped kinds.
assert fields["api_key"]["kind"] == "secret"
assert fields["api_key"]["is_set"] is False
assert fields["api_key"]["value"] == ""
assert fields["rerank"]["kind"] == "select"
assert {opt["value"] for opt in fields["rerank"]["options"]} == {"true", "false"}
assert "user_id" in fields and "agent_id" in fields
def test_put_memory_provider_config_writes_config_and_secret(self):
def test_put_memory_provider_config_writes_via_save_config_and_secret(self):
from hermes_constants import get_hermes_home
from hermes_cli.config import load_config, load_env
resp = self.client.put(
"/api/memory/providers/hindsight/config",
"/api/memory/providers/mem0/config",
json={
"values": {
"mode": "local_external",
"api_url": "http://localhost:8888",
"api_key": "hs-test-key",
"bank_id": "ben-bank",
"recall_budget": "high",
"user_id": "ben",
"agent_id": "agent-x",
"rerank": "false",
"api_key": "mem0-test-key",
}
},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert load_config()["memory"]["provider"] == "hindsight"
assert load_env()["HINDSIGHT_API_KEY"] == "hs-test-key"
# Provider is activated and the secret lands in the env store.
assert load_config()["memory"]["provider"] == "mem0"
assert load_env()["MEM0_API_KEY"] == "mem0-test-key"
config_path = get_hermes_home() / "hindsight" / "config.json"
# Non-secret values are persisted through the provider's own save_config()
# to its native location (mem0.json), not a path the endpoint hardcodes.
config_path = get_hermes_home() / "mem0.json"
provider_config = json.loads(config_path.read_text(encoding="utf-8"))
assert provider_config == {
"mode": "local_external",
"api_url": "http://localhost:8888",
"bank_id": "ben-bank",
"recall_budget": "high",
}
assert provider_config["user_id"] == "ben"
assert provider_config["agent_id"] == "agent-x"
assert provider_config["rerank"] == "false"
assert "api_key" not in provider_config # secret never written to the config file
def test_put_memory_provider_config_rejects_unsupported_select_value(self):
resp = self.client.put(
"/api/memory/providers/hindsight/config",
json={
"values": {
"mode": "local_embedded",
"api_url": "http://localhost:8888",
"bank_id": "hermes",
"recall_budget": "mid",
}
},
"/api/memory/providers/mem0/config",
json={"values": {"rerank": "maybe"}},
)
assert resp.status_code == 400
def test_put_skips_fields_gated_off_by_unmet_when_clause(self):
# Hindsight's llm_* fields are gated `when mode == local_embedded`.
# Saving in cloud mode must not persist them.
from hermes_constants import get_hermes_home
resp = self.client.put(
"/api/memory/providers/hindsight/config",
json={"values": {"mode": "cloud"}},
)
assert resp.status_code == 200
config_path = get_hermes_home() / "hindsight" / "config.json"
provider_config = json.loads(config_path.read_text(encoding="utf-8"))
assert provider_config["mode"] == "cloud"
assert "llm_model" not in provider_config
assert "llm_provider" not in provider_config
def test_put_unknown_memory_provider_returns_404(self):
resp = self.client.put(
"/api/memory/providers/nope/config", json={"values": {}}
@ -347,19 +360,11 @@ class TestWebServerEndpoints:
def test_get_memory_provider_config_does_not_return_secret(self):
self.client.put(
"/api/memory/providers/hindsight/config",
json={
"values": {
"mode": "cloud",
"api_url": "https://api.hindsight.vectorize.io",
"api_key": "secret-value",
"bank_id": "hermes",
"recall_budget": "mid",
}
},
"/api/memory/providers/mem0/config",
json={"values": {"api_key": "secret-value", "user_id": "ben"}},
)
resp = self.client.get("/api/memory/providers/hindsight/config")
resp = self.client.get("/api/memory/providers/mem0/config")
assert resp.status_code == 200
data = resp.json()