mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
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
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:
commit
163fab8d00
5 changed files with 106 additions and 38 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue