From 9cb0c62e65c03b0d8e8f067ab616e683b260f934 Mon Sep 17 00:00:00 2001 From: Erosika Date: Thu, 16 Jul 2026 10:40:00 -0400 Subject: [PATCH] feat(memory): restore the surface=declared routing from main Route the provider config endpoints on the surface query param exactly as main does: surface=declared serves the curated schema (now sourced from the plugins' config_schema.py instead of the deleted hermes_cli/memory_providers.py), while the default surface keeps serving the raw plugin schema that the web dashboard parses. Both surfaces honor the profile query param. The declared PUT returns {ok: true} to match main's contract; only the raw-surface PUT reports the activated provider. The desktop client requests surface=declared again, and the undeclared-provider tests use builtin now that honcho has a declared schema. --- apps/desktop/src/hermes.ts | 5 +- hermes_cli/web_server.py | 47 +++++++++--------- tests/hermes_cli/test_web_server.py | 76 +++++++++++++++++------------ 3 files changed, 69 insertions(+), 59 deletions(-) diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 9378d00954faa..7a47ba2c58178 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -516,17 +516,18 @@ export function saveHermesConfig(config: HermesConfigRecord): Promise<{ ok: bool }) } +// surface=declared serves the curated desktop schema; the dashboard consumes the raw plugin schema. export function getMemoryProviderConfig(provider: string): Promise { return window.hermesDesktop.api({ ...profileScoped(), - path: `/api/memory/providers/${encodeURIComponent(provider)}/config` + path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared` }) } export function saveMemoryProviderConfig(provider: string, values: Record): Promise<{ ok: boolean }> { return window.hermesDesktop.api<{ ok: boolean }>({ ...profileScoped(), - path: `/api/memory/providers/${encodeURIComponent(provider)}/config`, + path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`, method: 'PUT', body: { values } }) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 14bcbaa52a547..9c5ff3303974c 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -5522,33 +5522,25 @@ def _require_valid_memory_provider_name(name: str) -> None: @app.get("/api/memory/providers/{name}/config") -async def get_memory_provider_config(name: str, profile: Optional[str] = None): +async def get_memory_provider_config(name: str, surface: Optional[str] = None, profile: Optional[str] = None): _require_valid_memory_provider_name(name) def _run(): with _profile_scope(profile): - declared = get_provider_config_schema(name) - if declared is not None: - payload = _declared_provider_payload(declared) - payload["setup"] = _memory_provider_setup_info(name) - return payload + if surface == "declared": + declared = get_provider_config_schema(name) + if declared is None: + # Undeclared providers (e.g. builtin) have no desktop + # config surface; the generic panel renders nothing. + return {"name": name, "label": name, "docs_url": "", "fields": []} + return _declared_provider_payload(declared) + provider = _load_memory_provider(name) if provider is None: - # Undeclared providers (e.g. builtin) have no config surface. Return - # an empty schema so the generic panels simply render nothing. - return { - "name": name, - "label": name, - "docs_url": "", - "fields": [], - "actions": [], - "setup": _memory_provider_setup_info(name), - } - payload = _memory_provider_payload(name, provider) - # The desktop panel expects these keys on every provider payload. - payload.setdefault("docs_url", "") - payload.setdefault("actions", []) - return payload + # Undeclared providers (e.g. builtin) have no config surface. Return an + # empty schema so the generic panel simply renders nothing. + return {"name": name, "label": name, "fields": [], "setup": _memory_provider_setup_info(name)} + return _memory_provider_payload(name, provider) return await asyncio.to_thread(_run) @@ -5574,16 +5566,21 @@ async def setup_memory_provider(name: str, body: MemoryProviderSetupRequest): @app.put("/api/memory/providers/{name}/config") -async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate, profile: Optional[str] = None): +async def update_memory_provider_config( + name: str, body: MemoryProviderConfigUpdate, surface: Optional[str] = None, profile: Optional[str] = None +): _require_valid_memory_provider_name(name) values = body.values or {} def _run(): with _profile_scope(profile): - declared = get_provider_config_schema(name) - if declared is not None: + if surface == "declared": + declared = get_provider_config_schema(name) + if declared is None: + raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") _update_memory_provider_config(declared, _stringify_submitted_values(values)) - return {"ok": True, "active": declared.name} + return {"ok": True} + provider = _load_memory_provider(name) if provider is None: raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}") diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 219b7f4bba251..11b94372bfb00 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -469,16 +469,30 @@ class TestWebServerEndpoints: 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 {opt["value"] for opt in fields["mode"]["options"]} >= { + "cloud", + "local_external", + } + assert fields["api_url"]["kind"] == "text" + assert fields["api_url"]["value"] assert fields["bank_id"]["value"] == "hermes" assert fields["recall_budget"]["value"] == "mid" assert fields["api_key"]["kind"] == "secret" assert fields["api_key"]["is_set"] is False + assert fields["api_key"]["required"] is False + + def test_get_memory_provider_config_loads_dynamic_plugin_schema(self): + resp = self.client.get("/api/memory/providers/honcho/config") + + assert resp.status_code == 200 + data = resp.json() + fields = self._provider_field_map(data) + assert fields["api_key"]["kind"] == "secret" + assert fields["api_key"]["url"] == "https://app.honcho.dev" + assert fields["baseUrl"]["kind"] == "text" def test_instance_schema_serves_providers_without_declared_schema(self, monkeypatch): - # Providers with no config_schema.py fall back to the plugin instance's - # get_config_schema(); the payload gains the keys the desktop expects. + # The default surface serves the plugin instance's get_config_schema(). from hermes_cli import web_server class _Stub: @@ -498,8 +512,6 @@ class TestWebServerEndpoints: assert fields["api_key"]["kind"] == "secret" assert fields["api_key"]["url"] == "https://stub.example" assert fields["baseUrl"]["kind"] == "text" - assert data["actions"] == [] - assert data["docs_url"] == "" def test_declared_surface_serves_curated_hindsight_schema(self): resp = self.client.get("/api/memory/providers/hindsight/config?surface=declared") @@ -512,7 +524,7 @@ class TestWebServerEndpoints: assert fields["api_key"]["kind"] == "secret" def test_declared_surface_hides_undeclared_providers(self): - resp = self.client.get("/api/memory/providers/honcho/config?surface=declared") + resp = self.client.get("/api/memory/providers/builtin/config?surface=declared") assert resp.status_code == 200 assert resp.json()["fields"] == [] @@ -544,7 +556,7 @@ class TestWebServerEndpoints: def test_declared_surface_put_rejects_undeclared_provider(self): resp = self.client.put( - "/api/memory/providers/honcho/config?surface=declared", + "/api/memory/providers/builtin/config?surface=declared", json={"values": {"api_key": "x"}}, ) @@ -967,7 +979,7 @@ class TestWebServerEndpoints: # HOME isn't isolated by the suite; pin it so ~/.honcho can't leak in. monkeypatch.setenv("HOME", str(tmp_path)) - resp = self.client.get("/api/memory/providers/honcho/config") + resp = self.client.get("/api/memory/providers/honcho/config?surface=declared") assert resp.status_code == 200 data = resp.json() @@ -999,7 +1011,7 @@ class TestWebServerEndpoints: from hermes_cli.config import load_config, load_env resp = self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={ "values": { "apiKey": "hch-test-key", @@ -1014,7 +1026,7 @@ class TestWebServerEndpoints: ) assert resp.status_code == 200 - assert resp.json() == {"ok": True, "active": "honcho"} + assert resp.json() == {"ok": True} assert load_config()["memory"]["provider"] == "honcho" assert load_env()["HONCHO_API_KEY"] == "hch-test-key" @@ -1034,11 +1046,11 @@ class TestWebServerEndpoints: from hermes_constants import get_hermes_home self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"workspace": "myws"}}, ) self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"workspace": ""}}, ) @@ -1051,11 +1063,11 @@ class TestWebServerEndpoints: from hermes_constants import get_hermes_home self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"workspace": "myws"}}, ) self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"peerName": "eri"}}, ) @@ -1067,7 +1079,7 @@ class TestWebServerEndpoints: monkeypatch.setenv("HOME", str(tmp_path)) resp = self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"environment": "bogus"}}, ) @@ -1080,11 +1092,11 @@ class TestWebServerEndpoints: self._seed_local_honcho() self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"apiKey": "secret-value"}}, ) - resp = self.client.get("/api/memory/providers/honcho/config") + resp = self.client.get("/api/memory/providers/honcho/config?surface=declared") assert resp.status_code == 200 data = resp.json() @@ -1099,7 +1111,7 @@ class TestWebServerEndpoints: from hermes_constants import get_hermes_home self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"saveMessages": "false", "dialecticDynamic": "true"}}, ) @@ -1108,7 +1120,7 @@ class TestWebServerEndpoints: assert host["saveMessages"] is False assert host["dialecticDynamic"] is True - fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config").json()) + fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config?surface=declared").json()) assert fields["saveMessages"]["value"] == "false" assert fields["dialecticDynamic"]["value"] == "true" @@ -1118,7 +1130,7 @@ class TestWebServerEndpoints: from hermes_constants import get_hermes_home self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"dialecticMaxChars": "1200", "timeout": "2.5"}}, ) @@ -1128,7 +1140,7 @@ class TestWebServerEndpoints: # timeout is root-scoped and keeps its fractional part. assert cfg["timeout"] == 2.5 - fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config").json()) + fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config?surface=declared").json()) assert fields["dialecticMaxChars"]["value"] == "1200" def test_put_honcho_json_round_trips_object(self, monkeypatch, tmp_path): @@ -1137,14 +1149,14 @@ class TestWebServerEndpoints: from hermes_constants import get_hermes_home self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"userPeerAliases": '{"telegram_1": "eri"}'}}, ) host = json.loads((get_hermes_home() / "honcho.json").read_text(encoding="utf-8"))["hosts"]["hermes"] assert host["userPeerAliases"] == {"telegram_1": "eri"} - fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config").json()) + fields = self._provider_field_map(self.client.get("/api/memory/providers/honcho/config?surface=declared").json()) assert json.loads(fields["userPeerAliases"]["value"]) == {"telegram_1": "eri"} def test_put_honcho_first_save_merges_into_resolved_config(self, monkeypatch, tmp_path): @@ -1160,7 +1172,7 @@ class TestWebServerEndpoints: ) resp = self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"peerName": "eri"}}, ) @@ -1178,7 +1190,7 @@ class TestWebServerEndpoints: path = self._seed_local_honcho({"hosts": {"hermes.work": {"workspace": "w", "peerName": "eri"}}}) resp = self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"sessionStrategy": "per-repo"}}, ) @@ -1196,7 +1208,7 @@ class TestWebServerEndpoints: path = self._seed_local_honcho({"hosts": {"hermes": {"apiKey": "hch-at-oauth-token"}}}) resp = self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"apiKey": "manual-key"}}, ) @@ -1212,7 +1224,7 @@ class TestWebServerEndpoints: path = self._seed_local_honcho({"hosts": None}) resp = self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"workspace": "myws"}}, ) @@ -1234,7 +1246,7 @@ class TestWebServerEndpoints: worker_cfg.write_text(json.dumps({"hosts": {"hermes_worker": {"workspace": "kept"}}}), encoding="utf-8") resp = self.client.put( - "/api/memory/providers/honcho/config?profile=worker", + "/api/memory/providers/honcho/config?surface=declared&profile=worker", json={"values": {"peerName": "eri"}}, ) @@ -1247,7 +1259,7 @@ class TestWebServerEndpoints: assert "peerName" not in json.dumps(own) fields = self._provider_field_map( - self.client.get("/api/memory/providers/honcho/config?profile=worker").json() + self.client.get("/api/memory/providers/honcho/config?surface=declared&profile=worker").json() ) assert fields["peerName"]["value"] == "eri" @@ -1255,11 +1267,11 @@ class TestWebServerEndpoints: monkeypatch.setenv("HOME", str(tmp_path)) assert self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"dialecticMaxChars": "lots"}}, ).status_code == 400 assert self.client.put( - "/api/memory/providers/honcho/config", + "/api/memory/providers/honcho/config?surface=declared", json={"values": {"userPeerAliases": "{not json"}}, ).status_code == 400