mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-10-20 05:15:31 +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
|
|
@ -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