mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-24 16:54:43 +00:00
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:
parent
94a0cb283e
commit
101b9f8dcd
1 changed files with 278 additions and 75 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue