fix(desktop): restore curated declared schema for the provider panel

The desktop provider panel previously rendered the curated declarations
from hermes_cli/memory_providers.py: five hindsight fields, and no panel
at all for undeclared providers like honcho (OAuth connect only). The
dashboard provider-switching rework re-pointed the shared config route
at raw plugin schemas, so the desktop began dumping every internal field
(35 for hindsight) and grew a bespoke honcho panel.

Serve both surfaces from the same route: ?surface=declared returns the
curated schema (empty for undeclared providers) with the original
config-file + env-store write semantics; the dashboard keeps the raw
plugin schema unchanged. The desktop client opts into declared.
This commit is contained in:
Erosika 2026-07-09 17:01:35 -04:00 committed by Teknium
parent 861d69c7bb
commit c7e09f2571
3 changed files with 193 additions and 4 deletions

View file

@ -413,15 +413,16 @@ 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>({
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 }>({
path: `/api/memory/providers/${encodeURIComponent(provider)}/config`,
path: `/api/memory/providers/${encodeURIComponent(provider)}/config?surface=declared`,
method: 'PUT',
body: { values }
})

View file

@ -84,6 +84,11 @@ from gateway.status import (
parse_active_agents,
read_runtime_status,
)
from hermes_cli.memory_providers import (
MemoryProvider as DeclaredMemoryProvider,
ProviderField as DeclaredProviderField,
get_memory_provider as get_declared_memory_provider,
)
from utils import env_var_enabled
try:
@ -5169,9 +5174,129 @@ def _require_valid_memory_provider_name(name: str) -> None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
# ---------------------------------------------------------------------------
# Declared surface — curated desktop schema from hermes_cli.memory_providers.
# The desktop panel requests ?surface=declared; the dashboard keeps the raw
# plugin schema. Providers without a declaration render no desktop panel.
# ---------------------------------------------------------------------------
def _declared_provider_file_path(provider: DeclaredMemoryProvider) -> Path:
return get_hermes_home() / provider.name / "config.json"
def _read_declared_provider_file(provider: DeclaredMemoryProvider) -> Dict[str, Any]:
return _read_json_file(_declared_provider_file_path(provider))
def _declared_read_field_value(field: DeclaredProviderField, data: Dict[str, Any]) -> str:
for source_key in (field.key, *field.aliases):
value = data.get(source_key)
if value:
return str(value)
env_on_disk = load_env()
for env_key in field.env_fallbacks:
value = env_on_disk.get(env_key)
if value:
return str(value)
return field.default
def _declared_field_is_set(field: DeclaredProviderField, data: Dict[str, Any]) -> bool:
env_on_disk = load_env()
for env_key in (field.env_key, *field.env_fallbacks):
if env_key and env_on_disk.get(env_key):
return True
return any(data.get(source_key) for source_key in (field.key, *field.aliases))
def _declared_provider_payload(provider: DeclaredMemoryProvider) -> Dict[str, Any]:
data = _read_declared_provider_file(provider)
fields: List[Dict[str, Any]] = []
for field in provider.fields:
entry: Dict[str, Any] = {
"key": field.key,
"label": field.label,
"kind": field.kind,
"description": field.description,
"placeholder": field.placeholder,
"options": [
{"value": opt.value, "label": opt.label, "description": opt.description}
for opt in field.options
],
}
if field.is_secret:
# Secrets are write-only over the API; only expose whether one is set.
entry["value"] = ""
entry["is_set"] = _declared_field_is_set(field, data)
else:
value = _declared_read_field_value(field, data)
if field.kind == "select" and value not in field.allowed_values():
value = field.default
entry["value"] = value
entry["is_set"] = bool(value)
fields.append(entry)
return {"name": provider.name, "label": provider.label, "fields": fields}
def _coerce_declared_field_value(field: DeclaredProviderField, raw: str) -> str:
value = (raw or "").strip()
if field.kind == "select":
if not value:
value = field.default
if value not in field.allowed_values():
raise ValueError(f"Invalid value for '{field.key}'")
return value
return value or field.default
def _update_declared_provider_config(provider: DeclaredMemoryProvider, values: Dict[str, Any]) -> None:
existing = _read_declared_provider_file(provider)
json_values: Dict[str, Any] = {}
secrets: Dict[str, str] = {}
for field in provider.fields:
if field.is_secret:
submitted = str(values.get(field.key) or "").strip()
if submitted and field.env_key:
secrets[field.env_key] = submitted
continue
raw = (
values[field.key]
if field.key in values
else str(existing.get(field.key, field.default))
)
json_values[field.key] = _coerce_declared_field_value(field, str(raw))
path = _declared_provider_file_path(provider)
path.parent.mkdir(parents=True, exist_ok=True)
existing.update(json_values)
from utils import atomic_json_write
atomic_json_write(path, existing, mode=0o600)
for env_key, secret in secrets.items():
save_env_value(env_key, secret)
@app.get("/api/memory/providers/{name}/config")
async def get_memory_provider_config(name: str):
async def get_memory_provider_config(name: str, surface: Optional[str] = None):
_require_valid_memory_provider_name(name)
if surface == "declared":
declared = get_declared_memory_provider(name)
if declared is None:
# Undeclared providers (e.g. builtin, honcho) have no desktop
# config surface; the generic panel renders nothing.
return {"name": name, "label": name, "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
@ -5202,8 +5327,22 @@ 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):
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate, surface: Optional[str] = None):
_require_valid_memory_provider_name(name)
if surface == "declared":
declared = get_declared_memory_provider(name)
if declared is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")
try:
_update_declared_provider_config(declared, body.values or {})
return {"ok": True}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except Exception:
_log.exception("PUT /api/memory/providers/%s/config (declared) failed", name)
raise HTTPException(status_code=500, detail="Internal server error")
provider = _load_memory_provider(name)
if provider is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")

View file

@ -491,6 +491,55 @@ class TestWebServerEndpoints:
assert fields["api_key"]["url"] == "https://app.honcho.dev"
assert fields["baseUrl"]["kind"] == "text"
def test_declared_surface_serves_curated_hindsight_schema(self):
resp = self.client.get("/api/memory/providers/hindsight/config?surface=declared")
assert resp.status_code == 200
data = resp.json()
fields = self._provider_field_map(data)
assert set(fields) == {"mode", "api_key", "api_url", "bank_id", "recall_budget"}
assert fields["mode"]["kind"] == "select"
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")
assert resp.status_code == 200
assert resp.json()["fields"] == []
def test_declared_surface_put_writes_config_and_secret(self):
from hermes_constants import get_hermes_home
from hermes_cli.config import load_env
resp = self.client.put(
"/api/memory/providers/hindsight/config?surface=declared",
json={
"values": {
"mode": "local_external",
"api_url": "http://localhost:8888",
"api_key": "hs-declared-key",
}
},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True}
assert load_env()["HINDSIGHT_API_KEY"] == "hs-declared-key"
config_path = get_hermes_home() / "hindsight" / "config.json"
provider_config = json.loads(config_path.read_text(encoding="utf-8"))
assert provider_config["mode"] == "local_external"
assert provider_config["api_url"] == "http://localhost:8888"
assert "api_key" not in provider_config
def test_declared_surface_put_rejects_undeclared_provider(self):
resp = self.client.put(
"/api/memory/providers/honcho/config?surface=declared",
json={"values": {"api_key": "x"}},
)
assert resp.status_code == 404
def test_all_listed_memory_provider_configs_fetch(self):
resp = self.client.get("/api/memory")