diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 055f72ac19a1..2ecdfb1e7e44 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -247,10 +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). - '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 33aa771d32b5..860c89cb31f0 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -30,18 +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('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 238b4acf88e7..f6c43a550b8b 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1136,33 +1136,68 @@ 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() + + memory = cfg.get("memory") + configured = memory.get("provider") if isinstance(memory, dict) else None + current = _normalize_memory_provider_name(configured) + + 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, options: 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) - if merged != entry["options"]: - overlay[key] = {**entry, "options": merged} + + if isinstance(entry, dict) and isinstance(entry.get("options"), list) and options != entry["options"]: + overlay[key] = {**entry, "options": options} + + for kind in ("tts", "stt"): + entry = CONFIG_SCHEMA.get(f"{kind}.provider") + existing = entry.get("options") if isinstance(entry, dict) else None + + if isinstance(existing, list): + merge(f"{kind}.provider", _custom_provider_options(kind, list(existing), cfg)) + + merge("memory.provider", _memory_provider_schema_options(cfg)) + if not overlay: return CONFIG_SCHEMA - fields = dict(CONFIG_SCHEMA) - fields.update(overlay) - return fields + + return {**CONFIG_SCHEMA, **overlay} class ConfigUpdate(BaseModel): @@ -6230,11 +6265,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.