diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index b4bb6a82d0e4..1d8a2de0127f 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { Button } from '@/components/ui/button' -import { getElevenLabsVoices, getHermesConfigSchema, getMemoryStatus, saveHermesConfig } from '@/hermes' +import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' import { $keepAwake, setKeepAwake } from '@/store/keep-awake' import { notify, notifyError } from '@/store/notifications' @@ -76,10 +76,6 @@ export function ConfigSettings({ const schema = schemaResponse?.fields ?? null const [elevenLabsVoiceOptions, setElevenLabsVoiceOptions] = useState(null) const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState>({}) - // Discovered memory providers (bundled + user-installed + pip), so the - // memory.provider dropdown reflects what the backend actually serves rather - // than a hardcoded subset. null until the first fetch resolves. - const [memoryProviderOptions, setMemoryProviderOptions] = useState(null) const saveVersionRef = useRef(0) const savedDiscoverySignatureRef = useRef(undefined) const [saveVersion, setSaveVersion] = useState(0) @@ -130,27 +126,6 @@ export function ConfigSettings({ return () => void (cancelled = true) }, []) - useEffect(() => { - let cancelled = false - - getMemoryStatus() - .then(result => { - if (cancelled) { - return - } - - // Empty sentinel first (built-in only), then every discovered plugin. - setMemoryProviderOptions(['', ...result.providers.map(provider => provider.name)]) - }) - .catch(() => { - if (!cancelled) { - setMemoryProviderOptions(null) - } - }) - - return () => void (cancelled = true) - }, []) - useEffect(() => { if (!config || saveVersion === 0) { return @@ -333,9 +308,7 @@ export function ConfigSettings({ enumOptions={ key === 'tts.elevenlabs.voice_id' ? enumOptionsFor(key, getNested(config, key), config, elevenLabsVoiceOptions ?? undefined) - : key === 'memory.provider' - ? enumOptionsFor(key, getNested(config, key), config, memoryProviderOptions ?? undefined) - : enumOptionsFor(key, getNested(config, key), config) + : enumOptionsFor(key, getNested(config, key), config) } onChange={value => updateConfig(setNested(config, key, value))} optionLabels={key === 'tts.elevenlabs.voice_id' ? elevenLabsVoiceLabels : undefined} diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 789e8fce3062..2ecdfb1e7e44 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -247,12 +247,11 @@ export const ENUM_OPTIONS: Record = { 'code_execution.mode': ['project', 'strict'], 'context.engine': ['compressor', 'default', 'custom'], 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], - // Built-in memory is not a provider plugin: the empty sentinel renders as - // "Built-in only" and a legacy literal `builtin` value is only kept visible - // via enumOptionsFor's current-value passthrough (#49513). This static list - // is only a pre-load fallback: config-settings.tsx feeds enumOptionsFor the - // live discovered set (getMemoryStatus) so user-installed/pip providers show. - 'memory.provider': ['', 'honcho', 'hindsight'], + // NOTE: memory.provider is intentionally NOT listed here. Its options are + // discovery-driven and served by the backend config schema (merged + // per-request in web_server._schema_with_dynamic_provider_options), so + // config-field consumes schema.options directly — a static list here would + // shadow that and hide user-installed/pip providers (#49513). // Terminal execution backends — kept in sync with the dispatch ladder in // tools/terminal_tool.py::_create_environment (local/docker/singularity/ // modal/daytona/ssh). Remote backends need extra env (image, tokens, host). diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 825bc2909502..860c89cb31f0 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -30,37 +30,12 @@ describe('settings helpers', () => { expect(fieldCopyForSchemaKey(FIELD_DESCRIPTIONS, 'desktop.repo_scan_exclude_paths')).toBeTruthy() }) - it('lists the desktop memory provider options in their declared order', () => { - const options = enumOptionsFor('memory.provider', '', {}) - - // Built-in memory is not a provider plugin; the empty sentinel is the - // only built-in-shaped entry (#49513). - expect(options).toEqual(['', 'honcho', 'hindsight']) - }) - - it('keeps a legacy literal builtin value visible as the current selection', () => { - const options = enumOptionsFor('memory.provider', 'builtin', {}) - - expect(options).toEqual(['', 'honcho', 'hindsight', 'builtin']) - }) - - it('prefers the discovered provider set over the static fallback for memory.provider', () => { - // config-settings.tsx passes the live getMemoryStatus() providers as - // dynamicOptions; user-installed/pip providers must appear, not just the - // hardcoded honcho/hindsight subset. - const discovered = ['', 'honcho', 'hindsight', 'mem0', 'supermemory'] - const options = enumOptionsFor('memory.provider', '', {}, discovered) - - expect(options).toEqual(discovered) - }) - - it('keeps the current memory.provider value visible even if discovery omits it', () => { - // A provider selected in config but not (yet) returned by discovery must - // still render as the current selection rather than silently vanishing. - const discovered = ['', 'honcho'] - const options = enumOptionsFor('memory.provider', 'hindsight', {}, discovered) - - expect(options).toEqual(['', 'honcho', 'hindsight']) + it('does not shadow the backend schema options for memory.provider', () => { + // memory.provider options are discovery-driven and served by the backend + // config schema (merged per-request); enumOptionsFor must return undefined + // so config-field consumes schema.options instead of a stale static list. + expect(enumOptionsFor('memory.provider', '', {})).toBeUndefined() + expect(enumOptionsFor('memory.provider', 'honcho', {})).toBeUndefined() }) describe('isExternalMemoryProvider', () => { diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index 8882f98e63ad..13be2c6f0bf7 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -113,7 +113,7 @@ export function inferFieldSchema(value: unknown): ConfigFieldSchema { return { type: 'string' } } -// Backend schema omits some declared keys (e.g. memory.provider); config presence is the availability signal. +// Backend schema omits some declared keys; config presence is the availability signal. export function sectionFieldEntries( schema: Record, config: HermesConfigRecord diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 82d2c0da6784..cf0ceaaf7ef9 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1136,28 +1136,59 @@ def _custom_provider_options( return names -def _schema_with_voice_provider_options() -> Dict[str, Dict[str, Any]]: - """Return CONFIG_SCHEMA with per-request voice provider options merged. +def _memory_provider_schema_options(cfg: Dict[str, Any]) -> List[str]: + """Discovered memory providers for a per-request schema merge. - Computed at request time (not import time) so options reflect the - CURRENT config.yaml — including providers added after the server - started, and the profile-scoped config when the request carries a - ``profile`` param. The module-level ``CONFIG_SCHEMA`` is never mutated; - entries that change are shallow-copied onto a copied mapping. + Reuses the cheap directory scan of :func:`_memory_provider_options` and + additionally preserves the currently-configured provider, so a value + selected in config but not (yet) discoverable — e.g. a plugin removed from + disk — never silently vanishes from the dropdown. + """ + options = _memory_provider_options() + current = _normalize_memory_provider_name( + cfg.get("memory", {}).get("provider") if isinstance(cfg.get("memory"), dict) else None + ) + if current and current not in options: + options = [*options, current] + return options + + +def _schema_with_dynamic_provider_options() -> Dict[str, Dict[str, Any]]: + """Return CONFIG_SCHEMA with per-request discovery-driven options merged. + + Some ``*.provider`` selects have options that are discovered at runtime + (voice backends via the tts/stt registries + config.yaml command + providers; memory providers via a plugin-dir scan). The module-level + ``_SCHEMA_OVERRIDES`` freezes those lists at import time, so a provider + installed after the server started never appears. This recomputes them at + request time — reflecting the CURRENT config.yaml, the profile-scoped + config when the request carries a ``profile`` param, and mid-session + plugin installs — for every surface that reads the schema (desktop, CLI, + dashboard), with no extra frontend round-trips. + + The module-level ``CONFIG_SCHEMA`` is never mutated; entries that change + are shallow-copied onto a copied mapping. """ try: cfg = load_config() except Exception: # pragma: no cover - schema must survive config errors return CONFIG_SCHEMA overlay: Dict[str, Dict[str, Any]] = {} - for kind in ("tts", "stt"): - key = f"{kind}.provider" + + def _merge(key: str, merged: List[str]) -> None: entry = CONFIG_SCHEMA.get(key) if not isinstance(entry, dict) or not isinstance(entry.get("options"), list): - continue - merged = _custom_provider_options(kind, list(entry["options"]), cfg) + return if merged != entry["options"]: overlay[key] = {**entry, "options": merged} + + for kind in ("tts", "stt"): + entry = CONFIG_SCHEMA.get(f"{kind}.provider") + if isinstance(entry, dict) and isinstance(entry.get("options"), list): + _merge(f"{kind}.provider", _custom_provider_options(kind, list(entry["options"]), cfg)) + + _merge("memory.provider", _memory_provider_schema_options(cfg)) + if not overlay: return CONFIG_SCHEMA fields = dict(CONFIG_SCHEMA) @@ -6187,11 +6218,11 @@ async def get_defaults(): @app.get("/api/config/schema") async def get_schema(profile: Optional[str] = None): - # Voice provider options are merged per-request so user-declared - # command providers (tts.providers.* / stt.providers.*) added after - # server start still show up, scoped to the requested profile's config. + # Discovery-driven provider options (voice command providers + memory + # provider plugins) are merged per-request so providers added after server + # start still show up, scoped to the requested profile's config. with _config_profile_scope(profile): - fields = _schema_with_voice_provider_options() + fields = _schema_with_dynamic_provider_options() return {"fields": fields, "category_order": _CATEGORY_ORDER} diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index c981efd43c29..d3e684bb3c2f 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4633,6 +4633,44 @@ class TestBuildSchemaFromConfig: missing = set(list_memory_provider_names()) - options assert missing == set(), f"discovered providers missing from schema options: {missing}" + def test_dynamic_merge_recomputes_memory_provider_options(self, monkeypatch): + """The per-request schema merge re-discovers memory providers. + + The import-time _SCHEMA_OVERRIDES freezes the list at server start; + _schema_with_dynamic_provider_options must recompute it so a provider + installed mid-session is selectable without a restart. + """ + from hermes_cli import web_server + + monkeypatch.setattr(web_server, "load_config", lambda: {"memory": {"provider": "honcho"}}) + monkeypatch.setattr( + web_server, + "_memory_provider_options", + lambda: ["", "honcho", "hindsight", "freshly_installed"], + ) + + fields = web_server._schema_with_dynamic_provider_options() + + assert "freshly_installed" in fields["memory.provider"]["options"] + # The entry is copied, not mutated in place, and keeps its select type. + assert fields["memory.provider"]["type"] == "select" + assert web_server.CONFIG_SCHEMA["memory.provider"] is not fields["memory.provider"] + + def test_dynamic_merge_preserves_configured_memory_provider(self, monkeypatch): + """A configured-but-undiscovered provider stays visible as the selection. + + e.g. the plugin dir was removed but config still points at it — the + dropdown must not silently drop the active value. + """ + from hermes_cli import web_server + + monkeypatch.setattr(web_server, "load_config", lambda: {"memory": {"provider": "gone_from_disk"}}) + monkeypatch.setattr(web_server, "_memory_provider_options", lambda: ["", "honcho"]) + + fields = web_server._schema_with_dynamic_provider_options() + + assert "gone_from_disk" in fields["memory.provider"]["options"] + def test_approvals_mode_options_match_config_values(self): """approvals.mode select options must match the values accepted by config.py.