refactor(memory): move provider config schemas into their plugins

Each provider now declares its config surface in config_schema.py inside
its own plugin dir (plugins/memory/<name>/), loaded by file path like the
plugins themselves so plugin __init__ imports never reach the web server.
hermes_cli/memory_providers.py is gone; the shared field primitives and
loader live in plugins/memory/config_schema.py, and the schema tests move
to tests/plugins/memory/ alongside the other per-plugin suites.
This commit is contained in:
Erosika 2026-07-02 16:55:39 -04:00
parent 15c8c34c91
commit 2a632807e0
7 changed files with 319 additions and 253 deletions

View file

@ -69,11 +69,11 @@ from hermes_cli.config import (
write_platform_config_field,
_deep_merge,
)
from hermes_cli.memory_providers import (
MemoryProvider,
from plugins.memory.config_schema import (
ProviderConfigSchema,
ProviderField,
STORAGE_HONCHO_HOST_BLOCK,
get_memory_provider,
get_provider_config_schema,
)
from gateway.status import (
derive_gateway_busy,
@ -3978,11 +3978,11 @@ def _serialize_field_value(field: ProviderField, value: Any) -> str:
# — flat-json backend (default; reusable for simple providers) —
def _flat_json_path(provider: MemoryProvider) -> Path:
def _flat_json_path(provider: ProviderConfigSchema) -> Path:
return get_hermes_home() / provider.name / "config.json"
def _read_flat_json(provider: MemoryProvider) -> Dict[str, Any]:
def _read_flat_json(provider: ProviderConfigSchema) -> Dict[str, Any]:
path = _flat_json_path(provider)
if not path.exists():
return {}
@ -4076,7 +4076,7 @@ def _honcho_field_is_set(field: ProviderField, raw: Dict[str, Any], host_block:
return any(source.get(k) for source in sources for k in (field.key, *field.aliases))
def _memory_provider_payload(provider: MemoryProvider) -> Dict[str, Any]:
def _memory_provider_payload(provider: ProviderConfigSchema) -> Dict[str, Any]:
fields: List[Dict[str, Any]] = []
if provider.storage == STORAGE_HONCHO_HOST_BLOCK:
@ -4118,7 +4118,7 @@ def _memory_provider_payload(provider: MemoryProvider) -> Dict[str, Any]:
return {"name": provider.name, "label": provider.label, "docs_url": provider.docs_url, "fields": fields}
def _write_provider_flat(provider: MemoryProvider, values: Dict[str, str]) -> None:
def _write_provider_flat(provider: ProviderConfigSchema, values: Dict[str, str]) -> None:
from utils import atomic_json_write
existing = _read_flat_json(provider)
@ -4144,7 +4144,7 @@ def _write_provider_flat(provider: MemoryProvider, values: Dict[str, str]) -> No
atomic_json_write(path, existing, mode=0o600)
def _write_provider_honcho(provider: MemoryProvider, values: Dict[str, str]) -> None:
def _write_provider_honcho(provider: ProviderConfigSchema, values: Dict[str, str]) -> None:
"""Persist submitted fields to Honcho's real config for the active host.
Only keys present in ``values`` are touched, so a partial save (e.g. the
@ -4191,7 +4191,7 @@ def _write_provider_honcho(provider: MemoryProvider, values: Dict[str, str]) ->
@app.get("/api/memory/providers/{name}/config")
async def get_memory_provider_config(name: str):
provider = get_memory_provider(name)
provider = get_provider_config_schema(name)
if provider is None:
# Undeclared providers (e.g. builtin) have no config surface. Return an
# empty schema so the generic panel simply renders nothing.
@ -4201,7 +4201,7 @@ async def get_memory_provider_config(name: str):
@app.put("/api/memory/providers/{name}/config")
async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpdate):
provider = get_memory_provider(name)
provider = get_provider_config_schema(name)
if provider is None:
raise HTTPException(status_code=404, detail=f"Unknown memory provider: {name}")

View file

@ -0,0 +1,138 @@
"""Declarative configuration schema for memory provider plugins.
Each memory provider plugin *declares* its configurable surface in a
``config_schema.py`` next to its ``__init__.py`` the fields, their types,
which values are secrets, and (for selects) the allowed options. A single
generic renderer in the desktop UI and a single generic ``GET/PUT
/api/memory/providers/{name}/config`` endpoint pair drive the whole
experience, so adding a provider config surface is pure declaration with no
bespoke UI components.
Schema files are loaded by path (like the provider plugins themselves), never
via package import: plugin ``__init__.py`` files pull in the agent runtime,
which must not load into the web server. A ``config_schema.py`` may only
import from this module.
This module is intentionally pure data: it imports nothing from the
config/env layer. ``web_server`` owns the generic read/write logic that
interprets these declarations, dispatching on ``ProviderConfigSchema.storage``
to the matching backend.
"""
from __future__ import annotations
import importlib.util
import logging
from dataclasses import dataclass, field as dataclass_field
_log = logging.getLogger(__name__)
# Field kinds understood by the generic renderer.
KIND_TEXT = "text"
KIND_SELECT = "select"
KIND_SECRET = "secret"
KIND_BOOL = "bool"
KIND_NUMBER = "number"
KIND_JSON = "json"
# Storage backends understood by web_server. ``flat_json`` persists non-secret
# fields to ``<hermes_home>/<provider>/config.json``; ``honcho_host_block``
# targets Honcho's real config (honcho.json, scoped to the active profile host).
STORAGE_FLAT_JSON = "flat_json"
STORAGE_HONCHO_HOST_BLOCK = "honcho_host_block"
@dataclass(frozen=True)
class ProviderFieldOption:
"""A single choice for a ``select`` field."""
value: str
label: str
description: str = ""
@dataclass(frozen=True)
class ProviderField:
"""One configurable field on a memory provider.
A field is stored in exactly one place, decided by ``kind``:
* non-secret kinds persisted to the provider's config via its storage
backend under ``key``.
* ``secret`` persisted to the env store under ``env_key`` and never read
back out over the API (only an ``is_set`` flag is surfaced).
``aliases`` and ``env_fallbacks`` let a field read legacy values written by
earlier CLI/env setup without re-introducing per-provider code. ``inline``
marks the curated subset shown in the compact panel; the rest surface only
in the full-config modal. ``group`` buckets fields within that modal.
"""
key: str
label: str
kind: str = KIND_TEXT
default: str = ""
description: str = ""
placeholder: str = ""
options: tuple[ProviderFieldOption, ...] = ()
env_key: str | None = None
aliases: tuple[str, ...] = ()
env_fallbacks: tuple[str, ...] = ()
inline: bool = False
group: str = ""
# Where a host-block backend stores the field: "host" (per-profile host
# block) or "root" (config root). Ignored by the flat-json backend.
scope: str = "host"
@property
def is_secret(self) -> bool:
return self.kind == KIND_SECRET
def allowed_values(self) -> set[str]:
return {opt.value for opt in self.options}
@dataclass(frozen=True)
class ProviderConfigSchema:
"""A provider plugin's declared config surface."""
name: str
label: str
storage: str = STORAGE_FLAT_JSON
# Optional link to the provider's config docs, shown in the full-config modal.
docs_url: str = ""
fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple)
def inline_fields(self) -> tuple[ProviderField, ...]:
return tuple(f for f in self.fields if f.inline)
_SCHEMA_CACHE: dict[str, ProviderConfigSchema | None] = {}
def get_provider_config_schema(name: str) -> ProviderConfigSchema | None:
"""Return the ``CONFIG_SCHEMA`` declared by the provider plugin ``name``.
Providers without a ``config_schema.py`` (e.g. ``builtin``) return ``None``
and simply render no config panel.
"""
if name in _SCHEMA_CACHE:
return _SCHEMA_CACHE[name]
from plugins.memory import find_provider_dir
schema: ProviderConfigSchema | None = None
provider_dir = find_provider_dir(name)
path = provider_dir / "config_schema.py" if provider_dir else None
if path is not None and path.is_file():
try:
spec = importlib.util.spec_from_file_location(f"_hermes_memory_config_schema.{name}", path)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
schema = getattr(module, "CONFIG_SCHEMA", None)
except Exception:
_log.exception("failed to load config schema for memory provider %r", name)
_SCHEMA_CACHE[name] = schema
return schema

View file

@ -0,0 +1,76 @@
"""Hindsight's declared config surface — rendered by the generic desktop panel."""
from plugins.memory.config_schema import (
KIND_SECRET,
KIND_SELECT,
KIND_TEXT,
ProviderConfigSchema,
ProviderField,
ProviderFieldOption,
)
CONFIG_SCHEMA = ProviderConfigSchema(
name="hindsight",
label="Hindsight",
fields=(
ProviderField(
key="mode",
label="Mode",
kind=KIND_SELECT,
default="cloud",
description="How Hermes connects to Hindsight.",
options=(
ProviderFieldOption(
"cloud",
"Cloud",
"Hindsight Cloud API (lightweight, just needs an API key)",
),
ProviderFieldOption(
"local_external",
"Local External",
"Connect to an existing Hindsight instance",
),
),
inline=True,
),
ProviderField(
key="api_key",
label="API key",
kind=KIND_SECRET,
env_key="HINDSIGHT_API_KEY",
description="Used to authenticate with the Hindsight API.",
placeholder="Enter Hindsight API key",
inline=True,
),
ProviderField(
key="api_url",
label="API URL",
kind=KIND_TEXT,
default="https://api.hindsight.vectorize.io",
aliases=("apiUrl",),
env_fallbacks=("HINDSIGHT_API_URL",),
inline=True,
),
ProviderField(
key="bank_id",
label="Bank ID",
kind=KIND_TEXT,
default="hermes",
aliases=("bankId",),
inline=True,
),
ProviderField(
key="recall_budget",
label="Recall budget",
kind=KIND_SELECT,
default="mid",
aliases=("budget",),
options=(
ProviderFieldOption("low", "low"),
ProviderFieldOption("mid", "mid"),
ProviderFieldOption("high", "high"),
),
inline=True,
),
),
)

View file

@ -1,98 +1,17 @@
"""Declarative configuration schema for desktop memory providers.
"""Honcho's declared config surface — rendered by the generic desktop panel."""
Each memory provider *declares* its configurable surface here the fields, their
types, which values are secrets, and (for selects) the allowed options. A single
generic renderer in the desktop UI and a single generic ``GET/PUT
/api/memory/providers/{name}/config`` endpoint pair drive the whole experience,
so adding a new provider is pure declaration with no bespoke UI components.
This module is intentionally pure data: it imports nothing from the config/env
layer. ``web_server`` owns the generic read/write logic that interprets these
declarations, dispatching on ``MemoryProvider.storage`` to the matching backend.
"""
from __future__ import annotations
from dataclasses import dataclass, field as dataclass_field
# Field kinds understood by the generic renderer.
KIND_TEXT = "text"
KIND_SELECT = "select"
KIND_SECRET = "secret"
KIND_BOOL = "bool"
KIND_NUMBER = "number"
KIND_JSON = "json"
# Storage backends understood by web_server. ``flat_json`` persists non-secret
# fields to ``<hermes_home>/<provider>/config.json``; ``honcho_host_block``
# targets Honcho's real config (honcho.json, scoped to the active profile host).
STORAGE_FLAT_JSON = "flat_json"
STORAGE_HONCHO_HOST_BLOCK = "honcho_host_block"
@dataclass(frozen=True)
class ProviderFieldOption:
"""A single choice for a ``select`` field."""
value: str
label: str
description: str = ""
@dataclass(frozen=True)
class ProviderField:
"""One configurable field on a memory provider.
A field is stored in exactly one place, decided by ``kind``:
* non-secret kinds persisted to the provider's config via its storage
backend under ``key``.
* ``secret`` persisted to the env store under ``env_key`` and never read
back out over the API (only an ``is_set`` flag is surfaced).
``aliases`` and ``env_fallbacks`` let a field read legacy values written by
earlier CLI/env setup without re-introducing per-provider code. ``inline``
marks the curated subset shown in the compact panel; the rest surface only
in the full-config modal. ``group`` buckets fields within that modal.
"""
key: str
label: str
kind: str = KIND_TEXT
default: str = ""
description: str = ""
placeholder: str = ""
options: tuple[ProviderFieldOption, ...] = ()
env_key: str | None = None
aliases: tuple[str, ...] = ()
env_fallbacks: tuple[str, ...] = ()
inline: bool = False
group: str = ""
# Where a host-block backend stores the field: "host" (per-profile host
# block) or "root" (config root). Ignored by the flat-json backend.
scope: str = "host"
@property
def is_secret(self) -> bool:
return self.kind == KIND_SECRET
def allowed_values(self) -> set[str]:
return {opt.value for opt in self.options}
@dataclass(frozen=True)
class MemoryProvider:
"""A declared memory provider and its configurable fields."""
name: str
label: str
storage: str = STORAGE_FLAT_JSON
# Optional link to the provider's config docs, shown in the full-config modal.
docs_url: str = ""
fields: tuple[ProviderField, ...] = dataclass_field(default_factory=tuple)
def inline_fields(self) -> tuple[ProviderField, ...]:
return tuple(f for f in self.fields if f.inline)
from plugins.memory.config_schema import (
KIND_BOOL,
KIND_JSON,
KIND_NUMBER,
KIND_SECRET,
KIND_SELECT,
KIND_TEXT,
STORAGE_HONCHO_HOST_BLOCK,
ProviderConfigSchema,
ProviderField,
ProviderFieldOption,
)
# Reasoning effort levels shared by dialectic-related selects.
@ -105,7 +24,7 @@ _REASONING_LEVELS = (
)
HONCHO = MemoryProvider(
CONFIG_SCHEMA = ProviderConfigSchema(
name="honcho",
label="Honcho",
storage=STORAGE_HONCHO_HOST_BLOCK,
@ -387,84 +306,3 @@ HONCHO = MemoryProvider(
),
),
)
HINDSIGHT = MemoryProvider(
name="hindsight",
label="Hindsight",
fields=(
ProviderField(
key="mode",
label="Mode",
kind=KIND_SELECT,
default="cloud",
description="How Hermes connects to Hindsight.",
options=(
ProviderFieldOption(
"cloud",
"Cloud",
"Hindsight Cloud API (lightweight, just needs an API key)",
),
ProviderFieldOption(
"local_external",
"Local External",
"Connect to an existing Hindsight instance",
),
),
inline=True,
),
ProviderField(
key="api_key",
label="API key",
kind=KIND_SECRET,
env_key="HINDSIGHT_API_KEY",
description="Used to authenticate with the Hindsight API.",
placeholder="Enter Hindsight API key",
inline=True,
),
ProviderField(
key="api_url",
label="API URL",
kind=KIND_TEXT,
default="https://api.hindsight.vectorize.io",
aliases=("apiUrl",),
env_fallbacks=("HINDSIGHT_API_URL",),
inline=True,
),
ProviderField(
key="bank_id",
label="Bank ID",
kind=KIND_TEXT,
default="hermes",
aliases=("bankId",),
inline=True,
),
ProviderField(
key="recall_budget",
label="Recall budget",
kind=KIND_SELECT,
default="mid",
aliases=("budget",),
options=(
ProviderFieldOption("low", "low"),
ProviderFieldOption("mid", "mid"),
ProviderFieldOption("high", "high"),
),
inline=True,
),
),
)
# Registry of providers that expose a desktop config surface. Providers without
# an entry here (e.g. ``builtin``) simply render no config panel. Honcho leads.
MEMORY_PROVIDERS: dict[str, MemoryProvider] = {
HONCHO.name: HONCHO,
HINDSIGHT.name: HINDSIGHT,
}
def get_memory_provider(name: str) -> MemoryProvider | None:
"""Return the declared provider for ``name``, or ``None`` if undeclared."""
return MEMORY_PROVIDERS.get(name)

View file

@ -0,0 +1,16 @@
"""Tests for config-schema loading from memory provider plugin dirs."""
from plugins.memory.config_schema import get_provider_config_schema
def test_unknown_provider_is_none():
assert get_provider_config_schema("builtin") is None
def test_plugin_without_schema_is_none():
# mem0 is a real plugin dir that declares no config_schema.py.
assert get_provider_config_schema("mem0") is None
def test_schemas_are_cached_per_provider():
assert get_provider_config_schema("honcho") is get_provider_config_schema("honcho")

View file

@ -0,0 +1,51 @@
"""Tests for Hindsight's declared config surface."""
from plugins.memory.config_schema import (
KIND_SECRET,
KIND_SELECT,
get_provider_config_schema,
)
def test_hindsight_is_declared():
provider = get_provider_config_schema("hindsight")
assert provider is not None
assert provider.label == "Hindsight"
assert {field.key for field in provider.fields} == {
"mode",
"api_key",
"api_url",
"bank_id",
"recall_budget",
}
def test_fields_are_all_inline():
provider = get_provider_config_schema("hindsight")
assert provider is not None
# Hindsight is simple enough to render fully in the compact panel, so it
# never grows a Full config… modal.
assert all(field.inline for field in provider.fields)
def test_mode_gating_is_expressed_as_select_options():
provider = get_provider_config_schema("hindsight")
assert provider is not None
mode = next(field for field in provider.fields if field.key == "mode")
assert mode.kind == KIND_SELECT
assert mode.allowed_values() == {"cloud", "local_external"}
# local_embedded is intentionally unsupported on desktop.
assert "local_embedded" not in mode.allowed_values()
def test_api_key_is_a_secret_bound_to_env():
provider = get_provider_config_schema("hindsight")
assert provider is not None
api_key = next(field for field in provider.fields if field.key == "api_key")
assert api_key.kind == KIND_SECRET
assert api_key.is_secret is True
assert api_key.env_key == "HINDSIGHT_API_KEY"

View file

@ -1,14 +1,13 @@
"""Tests for the declarative memory-provider registry."""
"""Tests for Honcho's declared config surface."""
from hermes_cli.memory_providers import (
from plugins.memory.config_schema import (
KIND_BOOL,
KIND_JSON,
KIND_NUMBER,
KIND_SECRET,
KIND_SELECT,
MEMORY_PROVIDERS,
STORAGE_HONCHO_HOST_BLOCK,
get_memory_provider,
get_provider_config_schema,
)
# The curated set shown in the compact panel; everything else lives in the modal.
@ -23,12 +22,8 @@ INLINE_KEYS = {
}
def test_registry_lists_honcho_before_hindsight():
assert list(MEMORY_PROVIDERS) == ["honcho", "hindsight"]
def test_honcho_is_declared():
provider = get_memory_provider("honcho")
provider = get_provider_config_schema("honcho")
assert provider is not None
assert provider.label == "Honcho"
@ -39,8 +34,8 @@ def test_honcho_is_declared():
assert INLINE_KEYS <= set(keys)
def test_honcho_inline_fields_are_the_curated_subset():
provider = get_memory_provider("honcho")
def test_inline_fields_are_the_curated_subset():
provider = get_provider_config_schema("honcho")
assert provider is not None
assert {field.key for field in provider.inline_fields()} == INLINE_KEYS
@ -49,8 +44,8 @@ def test_honcho_inline_fields_are_the_curated_subset():
assert {"writeFrequency", "recallMode", "userPeerAliases"} <= non_inline
def test_honcho_declares_the_new_field_kinds():
provider = get_memory_provider("honcho")
def test_declares_the_new_field_kinds():
provider = get_provider_config_schema("honcho")
assert provider is not None
by_key = {f.key: f for f in provider.fields}
@ -61,8 +56,8 @@ def test_honcho_declares_the_new_field_kinds():
assert by_key["observationMode"].allowed_values() == {"directional", "unified"}
def test_honcho_selects_constrain_their_values():
provider = get_memory_provider("honcho")
def test_selects_constrain_their_values():
provider = get_provider_config_schema("honcho")
assert provider is not None
environment = next(f for f in provider.fields if f.key == "environment")
@ -74,8 +69,8 @@ def test_honcho_selects_constrain_their_values():
assert strategy.allowed_values() == {"per-directory", "per-repo", "per-session", "global"}
def test_honcho_api_key_is_a_secret_bound_to_env():
provider = get_memory_provider("honcho")
def test_api_key_is_a_secret_bound_to_env():
provider = get_provider_config_schema("honcho")
assert provider is not None
api_key = next(f for f in provider.fields if f.key == "apiKey")
@ -84,8 +79,8 @@ def test_honcho_api_key_is_a_secret_bound_to_env():
assert api_key.env_key == "HONCHO_API_KEY"
def test_honcho_root_scoped_fields_are_exactly_the_global_ones():
provider = get_memory_provider("honcho")
def test_root_scoped_fields_are_exactly_the_global_ones():
provider = get_provider_config_schema("honcho")
assert provider is not None
scopes = {f.key: f.scope for f in provider.fields}
@ -93,51 +88,3 @@ def test_honcho_root_scoped_fields_are_exactly_the_global_ones():
# baseUrl, timeout and sessions live at the config root in Honcho's schema;
# everything else is per-profile host-scoped.
assert root_keys == {"baseUrl", "timeout", "sessions"}
def test_hindsight_is_declared():
provider = get_memory_provider("hindsight")
assert provider is not None
assert provider.label == "Hindsight"
assert {field.key for field in provider.fields} == {
"mode",
"api_key",
"api_url",
"bank_id",
"recall_budget",
}
def test_hindsight_fields_are_all_inline():
provider = get_memory_provider("hindsight")
assert provider is not None
# Hindsight is simple enough to render fully in the compact panel, so it
# never grows a Full config… modal.
assert all(field.inline for field in provider.fields)
def test_hindsight_mode_gating_is_expressed_as_select_options():
provider = get_memory_provider("hindsight")
assert provider is not None
mode = next(field for field in provider.fields if field.key == "mode")
assert mode.kind == KIND_SELECT
assert mode.allowed_values() == {"cloud", "local_external"}
# local_embedded is intentionally unsupported on desktop.
assert "local_embedded" not in mode.allowed_values()
def test_hindsight_api_key_is_a_secret_bound_to_env():
provider = get_memory_provider("hindsight")
assert provider is not None
api_key = next(field for field in provider.fields if field.key == "api_key")
assert api_key.kind == KIND_SECRET
assert api_key.is_secret is True
assert api_key.env_key == "HINDSIGHT_API_KEY"
def test_unknown_provider_is_none():
assert get_memory_provider("builtin") is None