fix(dashboard): keep memory.provider in the config schema so Desktop's dropdown survives (#63886)

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
This commit is contained in:
Teknium 2026-07-13 18:56:51 -07:00 committed by GitHub
parent b663d50a6a
commit 861d69c7bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 73 additions and 2 deletions

View file

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

View file

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

View file

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

View file

@ -169,7 +169,16 @@ export default function ConfigPage() {
api
.getSchema()
.then((resp) => {
setSchema(resp.fields as Record<string, Record<string, unknown>>);
// 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<string, unknown>
>;
delete fields["memory.provider"];
setSchema(fields);
setCategoryOrder(resp.category_order ?? []);
})
.catch(() => {});