mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
03d9a95a74
commit
e48d18c8e2
16 changed files with 770 additions and 291 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue