mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
No bundled provider declares actions, and the motivating request (openviking, #56309) needs dynamic select options rather than actions. Removing the unconsumed POST dispatch surface keeps this PR focused on the config panel; the extension point can return as its own PR with its first real consumer.
144 lines
4.9 KiB
Python
144 lines
4.9 KiB
Python
"""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 (see its read/write dispatch).
|
|
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 = ""
|
|
# Longer help text surfaced as an info tooltip next to the field label.
|
|
info: str = ""
|
|
# Host-block placement: "host" (per-profile) or "root"; flat-json ignores it.
|
|
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] = {}
|
|
|
|
|
|
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. The cache keys on the resolved schema
|
|
file, not the name: user-installed plugins are per-profile, so one
|
|
profile's lookup must never answer for another's.
|
|
"""
|
|
|
|
from plugins.memory import find_provider_dir
|
|
|
|
provider_dir = find_provider_dir(name)
|
|
path = provider_dir / "config_schema.py" if provider_dir else None
|
|
if path is None or not path.is_file():
|
|
return None
|
|
|
|
key = str(path)
|
|
if key in _SCHEMA_CACHE:
|
|
return _SCHEMA_CACHE[key]
|
|
|
|
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:
|
|
# Never cache a failed load: it would pin an empty panel until restart.
|
|
_log.exception("failed to load config schema for memory provider %r", name)
|
|
return None
|
|
|
|
if schema is not None:
|
|
_SCHEMA_CACHE[key] = schema
|
|
return schema
|