Merge pull request #69077 from NousResearch/bb/desktop-memory-dropdown-discovery
Some checks are pending
CI / Detect affected areas (push) Waiting to run
CI / Python tests (push) Blocked by required conditions
CI / Python lints (push) Blocked by required conditions
CI / JS & TS checks (push) Blocked by required conditions
CI / Desktop E2E (push) Blocked by required conditions
CI / Docs Site (push) Blocked by required conditions
CI / Deny unrelated histories (push) Blocked by required conditions
CI / Check contributors (push) Blocked by required conditions
CI / Check uv.lock (push) Blocked by required conditions
CI / package-lock.json diff (push) Blocked by required conditions
CI / Lint Docker scripts (push) Blocked by required conditions
CI / Build&Test Docker image (push) Blocked by required conditions
CI / Supply-chain scan (push) Blocked by required conditions
CI / Review label gate (push) Blocked by required conditions
CI / OSV scan (push) Waiting to run
CI / CI review comment (live) (push) Blocked by required conditions
CI / All required checks pass (push) Blocked by required conditions
CI / CI timing report (push) Blocked by required conditions
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Waiting to run
auto-fix lint issues & formatting / Apply patch (push) Blocked by required conditions

fix(desktop): feed memory.provider dropdown from live discovery
This commit is contained in:
brooklyn! 2026-07-22 11:18:37 -05:00 committed by GitHub
commit 163fab8d00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 106 additions and 38 deletions

View file

@ -247,10 +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).
'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,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', () => {

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

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.