From 861d69c7bba8d2ea6a1cd170e989c901c74d32d1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:56:51 -0700 Subject: [PATCH] fix(dashboard): keep memory.provider in the config schema so Desktop's dropdown survives (#63886) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard's dedicated memory-provider UI (4b184cbe5) excluded memory.provider from /api/config/schema server-side. Desktop's settings page builds its field list from that schema, so the Memory Provider dropdown silently vanished from Desktop after v0.18.1. - web_server.py: restore memory.provider as a select in _SCHEMA_OVERRIDES, with options built from plugins.memory discovery (was a stale hardcoded [builtin, honcho] list before the removal) - plugins/memory: add list_memory_provider_names() — directory-scan-only name listing, safe at module import time (no provider imports) - web ConfigPage: hide memory.provider client-side instead — the Plugins page owns the dedicated provider-switching UI there - tests: schema contract (select present, category memory, builtin sentinel) + invariant that every discoverable provider is selectable --- hermes_cli/web_server.py | 25 ++++++++++++++++++++++++- plugins/memory/__init__.py | 11 +++++++++++ tests/hermes_cli/test_web_server.py | 28 ++++++++++++++++++++++++++++ web/src/pages/ConfigPage.tsx | 11 ++++++++++- 4 files changed, 73 insertions(+), 2 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 7365d241c09f..fed4b066d290 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -610,7 +610,30 @@ async def _token_auth_seam(request: Request, call_next): # --------------------------------------------------------------------------- # Manual overrides for fields that need select options or custom types +def _memory_provider_options() -> List[str]: + """Discovered memory providers for the ``memory.provider`` select. + + Directory-scan only (no provider imports), so it's safe at module import + time. ``""`` (built-in) is always first; discovery failures degrade to the + bundled defaults rather than dropping the field. + """ + options = ["", "builtin"] + try: + from plugins.memory import list_memory_provider_names + + options.extend(list_memory_provider_names()) + except Exception: + options.extend(["honcho"]) + # Dedupe, preserve order + return list(dict.fromkeys(options)) + + _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { + "memory.provider": { + "type": "select", + "description": "Memory provider plugin", + "options": _memory_provider_options(), + }, "model": { "type": "string", "description": "Default model (e.g. anthropic/claude-sonnet-4.6)", @@ -780,7 +803,7 @@ def _build_schema_from_config( full_key = f"{prefix}.{key}" if prefix else key # Skip internal / version keys - if full_key in {"_config_version", "memory.provider"}: + if full_key in {"_config_version"}: continue # Category is the first path component for nested keys, or "general" diff --git a/plugins/memory/__init__.py b/plugins/memory/__init__.py index 3f92b5cbb2a3..cccda75ce849 100644 --- a/plugins/memory/__init__.py +++ b/plugins/memory/__init__.py @@ -143,6 +143,17 @@ def find_provider_dir(name: str) -> Optional[Path]: # Public API # --------------------------------------------------------------------------- +def list_memory_provider_names() -> List[str]: + """Cheap name-only listing of discoverable memory providers. + + Unlike :func:`discover_memory_providers`, this does NOT import provider + modules or run availability checks — it's a directory scan only, safe to + call at module-import time (e.g. when building the dashboard config + schema). + """ + return sorted({name for name, _ in _iter_provider_dirs()}) + + def discover_memory_providers() -> List[Tuple[str, str, bool]]: """Scan bundled and user-installed directories for available providers. diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index f88d60c56c57..19d379dfcd0d 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -3645,6 +3645,34 @@ class TestBuildSchemaFromConfig: assert "options" in entry assert "local" in entry["options"] + def test_memory_provider_field_present_as_select(self): + """memory.provider must stay in the config schema. + + Desktop's settings page builds its field list from /api/config/schema — + a key excluded here silently vanishes from Desktop's Memory section + (regression: the dashboard's dedicated memory-provider UI excluded the + key server-side, breaking Desktop's dropdown). The dashboard hides the + field client-side instead. + """ + from hermes_cli.web_server import CONFIG_SCHEMA + entry = CONFIG_SCHEMA["memory.provider"] + assert entry["type"] == "select" + assert entry["category"] == "memory" + options = entry["options"] + # Built-in sentinel first, plus at least one discovered provider. + assert options[0] == "" + assert "builtin" in options + assert len(options) >= 3 + + def test_memory_provider_options_cover_discovered_providers(self): + """Every provider the /api/memory endpoint can activate is selectable.""" + from hermes_cli.web_server import CONFIG_SCHEMA + from plugins.memory import list_memory_provider_names + + options = set(CONFIG_SCHEMA["memory.provider"]["options"]) + missing = set(list_memory_provider_names()) - options + assert missing == set(), f"discovered providers missing from schema options: {missing}" + def test_approvals_mode_options_match_config_values(self): """approvals.mode select options must match the values accepted by config.py. diff --git a/web/src/pages/ConfigPage.tsx b/web/src/pages/ConfigPage.tsx index c6bff27f1d78..dcb88282002c 100644 --- a/web/src/pages/ConfigPage.tsx +++ b/web/src/pages/ConfigPage.tsx @@ -169,7 +169,16 @@ export default function ConfigPage() { api .getSchema() .then((resp) => { - setSchema(resp.fields as Record>); + // memory.provider has a dedicated management UI on the Plugins page + // (provider cards + guided setup/switch flow). Hide it from the + // generic config form so the two surfaces don't fight; the schema + // keeps the field for other consumers (Desktop settings). + const fields = { ...resp.fields } as Record< + string, + Record + >; + delete fields["memory.provider"]; + setSchema(fields); setCategoryOrder(resp.category_order ?? []); }) .catch(() => {});