feat(memory): dispatch /config reads+writes on provider storage backend

Generalize the memory-provider config endpoints to dispatch on
provider.storage: flat-json for simple providers, honcho_host_block for
Honcho's real profile-scoped honcho.json. Adds kind-aware coercion
(bool/number/json) and partial-save semantics so the inline panel never
clobbers full-config-only fields.
This commit is contained in:
Erosika 2026-06-22 14:46:46 -04:00
parent 94a0cb283e
commit 101b9f8dcd

View file

@ -72,6 +72,7 @@ from hermes_cli.config import (
from hermes_cli.memory_providers import (
MemoryProvider,
ProviderField,
STORAGE_HONCHO_HOST_BLOCK,
get_memory_provider,
)
from gateway.status import (
@ -3874,12 +3875,115 @@ def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]:
return config
def _memory_provider_config_path(provider: MemoryProvider) -> Path:
# ── Memory provider config ──────────────────────────────────────────────────
# One generic GET/PUT pair drives every declared provider. Reads/writes dispatch
# on ``provider.storage`` so a new simple provider is pure declaration, while
# Honcho persists to its real host-scoped config without bespoke endpoints.
def _provider_field_entry(field: ProviderField) -> Dict[str, Any]:
"""Static, storage-independent shape of one field for the UI payload."""
return {
"key": field.key,
"label": field.label,
"kind": field.kind,
"description": field.description,
"placeholder": field.placeholder,
"inline": field.inline,
"group": field.group,
"options": [
{"value": opt.value, "label": opt.label, "description": opt.description}
for opt in field.options
],
}
# Sentinel: a coerced value of _UNSET means "remove this key" (blank text /
# number / json falls back to the host or built-in default).
_UNSET: Any = object()
_TRUTHY = {"1", "true", "yes", "on"}
def _coerce_field_value(field: ProviderField, raw: str) -> Any:
"""Coerce a submitted non-secret value to its native JSON type.
Values arrive as strings over the API; this converts them to the type the
Honcho resolver expects (bool/number/list/dict), so e.g. a boolean is stored
as a JSON ``false`` rather than the string ``"false"`` (which would read as
truthy). Returns ``_UNSET`` when the field should be removed. Raises
``ValueError`` on malformed input.
"""
value = (raw or "").strip()
kind = field.kind
if kind == "select":
if not value:
value = field.default
if value not in field.allowed_values():
raise ValueError(f"Invalid value for '{field.key}'")
return value
if kind == "bool":
return value.lower() in _TRUTHY
if kind == "number":
if not value:
return _UNSET
try:
number = float(value)
except ValueError as exc:
raise ValueError(f"Invalid number for '{field.key}'") from exc
return int(number) if number.is_integer() else number
if kind == "json":
if not value:
return _UNSET
try:
parsed = json.loads(value)
except (ValueError, TypeError) as exc:
raise ValueError(f"Invalid JSON for '{field.key}'") from exc
if not isinstance(parsed, (dict, list)):
raise ValueError(f"'{field.key}' must be a JSON object or array")
return parsed
# text / secret — blank clears the key so it falls back to host/default.
return value if value else _UNSET
def _serialize_field_value(field: ProviderField, value: Any) -> str:
"""Render a stored native value as the string the generic UI edits.
``None`` (key absent) yields the field's declared default. Bools become
``"true"``/``"false"``, JSON objects/arrays are re-encoded, numbers are
stringified so the renderer's per-kind controls always get the shape they
expect regardless of how the value sits on disk.
"""
if value is None:
return field.default
if field.kind == "bool":
if isinstance(value, str):
return "true" if value.strip().lower() in _TRUTHY else "false"
return "true" if value else "false"
if field.kind == "json":
if isinstance(value, (dict, list)):
return json.dumps(value)
return str(value)
return str(value)
# — flat-json backend (default; reusable for simple providers) —
def _flat_json_path(provider: MemoryProvider) -> Path:
return get_hermes_home() / provider.name / "config.json"
def _read_memory_provider_file(provider: MemoryProvider) -> Dict[str, Any]:
path = _memory_provider_config_path(provider)
def _read_flat_json(provider: MemoryProvider) -> Dict[str, Any]:
path = _flat_json_path(provider)
if not path.exists():
return {}
try:
@ -3890,26 +3994,25 @@ def _read_memory_provider_file(provider: MemoryProvider) -> Dict[str, Any]:
return data if isinstance(data, dict) else {}
def _read_field_value(field: ProviderField, data: Dict[str, Any]) -> str:
"""Resolve the stored value for a non-secret field, honoring legacy reads."""
def _read_flat_field(field: ProviderField, data: Dict[str, Any]) -> Any:
"""Return the stored native value, or ``None`` when unset.
Presence (``key in data``) decides, not truthiness, so a stored ``False`` or
``0`` survives instead of being mistaken for "unset".
"""
for source_key in (field.key, *field.aliases):
value = data.get(source_key)
if value:
return str(value)
if source_key in data and data[source_key] is not None:
return data[source_key]
env_on_disk = load_env()
for env_key in field.env_fallbacks:
value = env_on_disk.get(env_key)
if value:
return str(value)
return field.default
return value
return None
def _field_is_set(field: ProviderField, data: Dict[str, Any]) -> bool:
"""Whether a secret field has a value anywhere it may have been written."""
def _flat_field_is_set(field: ProviderField, data: Dict[str, Any]) -> bool:
env_on_disk = load_env()
for env_key in (field.env_key, *field.env_fallbacks):
if env_key and env_on_disk.get(env_key):
@ -3917,50 +4020,173 @@ def _field_is_set(field: ProviderField, data: Dict[str, Any]) -> bool:
return any(data.get(source_key) for source_key in (field.key, *field.aliases))
# — honcho host-block backend —
def _honcho_resolvers():
"""Lazily import the Honcho plugin's resolvers (optional plugin)."""
from plugins.memory.honcho.client import _host_block, resolve_active_host, resolve_config_path
return resolve_active_host, resolve_config_path, _host_block
def _honcho_read_sources() -> tuple[Dict[str, Any], str, Dict[str, Any]]:
"""Return (root config, active host key, host block) for the current profile."""
resolve_active_host, resolve_config_path, host_block_of = _honcho_resolvers()
host = resolve_active_host()
path = resolve_config_path()
raw: Dict[str, Any] = {}
if path.exists():
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
raw = loaded if isinstance(loaded, dict) else {}
except Exception:
_log.warning("Failed to read Honcho config from %s", path, exc_info=True)
return raw, host, host_block_of(raw, host)
def _read_honcho_field(field: ProviderField, raw: Dict[str, Any], host_block: Dict[str, Any]) -> Any:
"""Return the stored native value for a host-block field, or ``None``.
Host scope checks the per-profile block before the config root; presence
wins over truthiness so ``False``/``0`` survive.
"""
sources = (host_block, raw) if field.scope == "host" else (raw,)
for source in sources:
for source_key in (field.key, *field.aliases):
if source_key in source and source[source_key] is not None:
return source[source_key]
env_on_disk = load_env()
for env_key in field.env_fallbacks:
value = env_on_disk.get(env_key)
if value:
return value
return None
def _honcho_field_is_set(field: ProviderField, raw: Dict[str, Any], host_block: Dict[str, Any]) -> bool:
env_on_disk = load_env()
for env_key in (field.env_key, *field.env_fallbacks):
if env_key and env_on_disk.get(env_key):
return True
sources = (host_block, raw) if field.scope == "host" else (raw,)
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]:
data = _read_memory_provider_file(provider)
fields: List[Dict[str, Any]] = []
if provider.storage == STORAGE_HONCHO_HOST_BLOCK:
raw, host, host_block = _honcho_read_sources()
else:
data = _read_flat_json(provider)
for field in provider.fields:
entry: Dict[str, Any] = {
"key": field.key,
"label": field.label,
"kind": field.kind,
"description": field.description,
"placeholder": field.placeholder,
"options": [
{"value": opt.value, "label": opt.label, "description": opt.description}
for opt in field.options
],
}
entry = _provider_field_entry(field)
if field.is_secret:
# Secrets are write-only over the API; only expose whether one is set.
entry["value"] = ""
entry["is_set"] = _field_is_set(field, data)
else:
value = _read_field_value(field, data)
if field.kind == "select" and value not in field.allowed_values():
value = field.default
entry["value"] = value
entry["is_set"] = bool(value)
entry["value"] = "" # secrets are write-only over the API
if provider.storage == STORAGE_HONCHO_HOST_BLOCK:
entry["is_set"] = _honcho_field_is_set(field, raw, host_block)
else:
entry["is_set"] = _flat_field_is_set(field, data)
fields.append(entry)
continue
if provider.storage == STORAGE_HONCHO_HOST_BLOCK:
native = _read_honcho_field(field, raw, host_block)
# Surface the resolved host so the user sees the peer mapping
# Honcho will actually use when these fields are left blank.
if not field.placeholder and field.key in {"workspace", "aiPeer"}:
entry["placeholder"] = host
else:
native = _read_flat_field(field, data)
value = _serialize_field_value(field, native)
if field.kind == "select" and value not in field.allowed_values():
value = field.default
entry["value"] = value
if provider.storage == STORAGE_HONCHO_HOST_BLOCK:
entry["is_set"] = _honcho_field_is_set(field, raw, host_block)
else:
entry["is_set"] = bool(value)
fields.append(entry)
return {"name": provider.name, "label": provider.label, "fields": fields}
return {"name": provider.name, "label": provider.label, "docs_url": provider.docs_url, "fields": fields}
def _coerce_field_value(field: ProviderField, raw: str) -> str:
"""Validate and normalize a submitted non-secret value, or raise ValueError."""
def _write_provider_flat(provider: MemoryProvider, values: Dict[str, str]) -> None:
from utils import atomic_json_write
value = (raw or "").strip()
if field.kind == "select":
if not value:
value = field.default
if value not in field.allowed_values():
raise ValueError(f"Invalid value for '{field.key}'")
return value
return value or field.default
existing = _read_flat_json(provider)
for field in provider.fields:
if field.is_secret:
submitted = (values.get(field.key) or "").strip()
if submitted and field.env_key:
save_env_value(field.env_key, submitted)
continue
if field.key not in values:
continue
coerced = _coerce_field_value(field, values[field.key])
if coerced is _UNSET:
existing.pop(field.key, None)
for alias in field.aliases:
existing.pop(alias, None)
else:
existing[field.key] = coerced
path = _flat_json_path(provider)
path.parent.mkdir(parents=True, exist_ok=True)
atomic_json_write(path, existing, mode=0o600)
def _write_provider_honcho(provider: MemoryProvider, 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
inline panel) never clobbers fields owned by the full-config editor. Blank
text clears a key so it falls back to the host/default mapping.
"""
from utils import atomic_json_write
resolve_active_host, _resolve_config_path, _host_block = _honcho_resolvers()
host = resolve_active_host()
path = get_hermes_home() / "honcho.json"
cfg: Dict[str, Any] = {}
if path.exists():
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
cfg = loaded if isinstance(loaded, dict) else {}
except Exception:
_log.warning("Failed to read Honcho config from %s", path, exc_info=True)
host_block = cfg.setdefault("hosts", {}).setdefault(host, {})
for field in provider.fields:
if field.is_secret:
submitted = (values.get(field.key) or "").strip()
if submitted and field.env_key:
save_env_value(field.env_key, submitted)
continue
if field.key not in values:
continue
target = host_block if field.scope == "host" else cfg
coerced = _coerce_field_value(field, values[field.key])
if coerced is _UNSET:
target.pop(field.key, None)
for alias in field.aliases:
target.pop(alias, None)
else:
target[field.key] = coerced
path.parent.mkdir(parents=True, exist_ok=True)
atomic_json_write(path, cfg, mode=0o600)
@app.get("/api/memory/providers/{name}/config")
@ -3969,7 +4195,7 @@ async def get_memory_provider_config(name: str):
if provider is None:
# Undeclared providers (e.g. builtin) have no config surface. Return an
# empty schema so the generic panel simply renders nothing.
return {"name": name, "label": name, "fields": []}
return {"name": name, "label": name, "docs_url": "", "fields": []}
return _memory_provider_payload(provider)
@ -3982,23 +4208,10 @@ async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpd
values = body.values or {}
try:
existing = _read_memory_provider_file(provider)
json_values: Dict[str, Any] = {}
secrets: Dict[str, str] = {}
for field in provider.fields:
if field.is_secret:
submitted = (values.get(field.key) or "").strip()
if submitted and field.env_key:
secrets[field.env_key] = submitted
continue
raw = (
values[field.key]
if field.key in values
else str(existing.get(field.key, field.default))
)
json_values[field.key] = _coerce_field_value(field, raw)
if provider.storage == STORAGE_HONCHO_HOST_BLOCK:
_write_provider_honcho(provider, values)
else:
_write_provider_flat(provider, values)
config = load_config()
memory_config = config.get("memory")
@ -4008,16 +4221,6 @@ async def update_memory_provider_config(name: str, body: MemoryProviderConfigUpd
memory_config["provider"] = provider.name
save_config(config)
path = _memory_provider_config_path(provider)
path.parent.mkdir(parents=True, exist_ok=True)
existing.update(json_values)
from utils import atomic_json_write
atomic_json_write(path, existing, mode=0o600)
for env_key, secret in secrets.items():
save_env_value(env_key, secret)
return {"ok": True}
except HTTPException:
raise