refactor: make memory.provider schema-driven instead of a 2nd fetch

Addresses review on #69077. The first pass added a second, heavier
round-trip (`GET /api/memory` -> `_discover_memory_provider_statuses()`,
which imports every provider module and probes install state) just to
fill the desktop dropdown, and left `schema.options` for memory.provider
dead — three sources of truth for one list.

Root cause is narrower: the desktop schema *already* carried a
discovery-driven `memory.provider` option list (`_SCHEMA_OVERRIDES` ->
`_memory_provider_options()`), but `enumOptionsFor` returned the static
`ENUM_OPTIONS['memory.provider']`, which shadowed `schema.options` in
config-field.tsx. The only real gap was liveness: `_SCHEMA_OVERRIDES` is
frozen at import time, so a provider installed mid-session never showed.

Fix at the layer the rest of this stack already uses:

- Backend: generalize `_schema_with_voice_provider_options` ->
  `_schema_with_dynamic_provider_options`, which now also recomputes
  `memory.provider` options per request (cheap plugin-dir scan via
  `_memory_provider_options`, plus current-value preservation). Fixes the
  same staleness for CLI + dashboard, not just desktop.
- Frontend: drop the `memory.provider` entry from `ENUM_OPTIONS` so
  `enumOptionsFor` returns undefined and config-field consumes the
  discovery-driven `schema.options` directly. No new frontend round-trips.
- Remove the now-unnecessary `getMemoryStatus()` fetch/state/wiring in
  config-settings.tsx (reverted to main).
- Fix the stale `helpers.ts` comment ("schema omits memory.provider").

Tests: backend tests for the per-request merge (recomputes discovered
providers; preserves a configured-but-undiscovered value); frontend test
asserts enumOptionsFor no longer shadows the schema for memory.provider.

Co-authored-by: brooklyn! <770929+OutThisLife@users.noreply.github.com>
This commit is contained in:
Austin Pickett 2026-07-22 00:15:10 -04:00
parent 3493a6c73c
commit baa3bf3915
6 changed files with 98 additions and 82 deletions

View file

@ -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<string[] | null>(null)
const [elevenLabsVoiceLabels, setElevenLabsVoiceLabels] = useState<Record<string, string>>({})
// 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<string[] | null>(null)
const saveVersionRef = useRef(0)
const savedDiscoverySignatureRef = useRef<string | undefined>(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}

View file

@ -247,12 +247,11 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'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).

View file

@ -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', () => {

View file

@ -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<string, ConfigFieldSchema>,
config: HermesConfigRecord

View file

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

View file

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