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.
This commit is contained in:
Erosika 2026-07-16 10:40:00 -04:00
parent b9663653f8
commit 9cb0c62e65
3 changed files with 69 additions and 59 deletions

View File

@ -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<MemoryProviderConfig> {
return window.hermesDesktop.api<MemoryProviderConfig>({
...profileScoped(),
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`
})
}
export function saveMemoryProviderConfig(provider: string, values: Record<string, string>): 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 }
})

View File

@ -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}")

View File

@ -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