ReMe/integrations/hermes_agent/config.py
jinliyl 1be61b1e4c
feat(integrations): add dual-mode Hermes memory provider (#533)
* feat(integrations): add dual-mode Hermes memory provider

* style(integrations): apply repository formatting

* fix(integrations): address Hermes provider review

* fix(integrations): bound embedded recall startup cleanup

* fix(integrations): finish embedded application cleanup

* docs(integrations): expand Hermes verification guide
2026-09-11 12:25:18 +08:00

208 lines
7 KiB
Python

"""Profile-local configuration for the Hermes ReMe memory provider."""
from __future__ import annotations
import json
import math
import os
import tempfile
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any
from .client import normalize_http_endpoint
CONFIG_DIRECTORY = "reme"
CONFIG_FILENAME = "config.json"
LEGACY_CONFIG_FILENAME = "reme.json"
VALID_MODES = {"http", "embedded"}
class ReMeConfigError(ValueError):
"""Raised when provider configuration is invalid."""
@dataclass(frozen=True)
class ReMeConfig:
"""Validated settings shared by the provider and both backends."""
mode: str = "http"
endpoint: str = "http://127.0.0.1:2333"
workspace_dir: str = ""
reme_config: str = "default"
request_timeout: float = 600.0
recall_timeout: float = 5.0
health_timeout: float = 2.0
health_retry_seconds: float = 30.0
shutdown_timeout: float = 30.0
recall_limit: int = 5
def config_path(hermes_home: str | Path) -> Path:
"""Return the path used by Hermes' generic provider configuration UI."""
return Path(hermes_home).expanduser() / CONFIG_DIRECTORY / CONFIG_FILENAME
def legacy_config_path(hermes_home: str | Path) -> Path:
"""Return the pre-dashboard configuration path retained for compatibility."""
return Path(hermes_home).expanduser() / LEGACY_CONFIG_FILENAME
def _default_hermes_home() -> Path:
from hermes_constants import get_hermes_home
return Path(get_hermes_home())
def _read_json_object(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ReMeConfigError(
f"Unable to read ReMe provider config {path}: {exc}",
) from exc
if not isinstance(loaded, dict):
raise ReMeConfigError(
f"ReMe provider config must contain a JSON object: {path}",
)
return loaded
def _read_config_values(home: Path) -> dict[str, Any]:
"""Merge legacy values under the current Dashboard-managed config."""
values = _read_json_object(legacy_config_path(home))
values.update(_read_json_object(config_path(home)))
return values
def _positive_float(value: Any, key: str, default: float) -> float:
if value in (None, ""):
return default
try:
number = float(value)
except (TypeError, ValueError) as exc:
raise ReMeConfigError(f"'{key}' must be a positive number") from exc
if not math.isfinite(number) or number <= 0:
raise ReMeConfigError(f"'{key}' must be a positive number")
return number
def _positive_int(value: Any, key: str, default: int) -> int:
if value in (None, ""):
return default
if isinstance(value, bool):
raise ReMeConfigError(f"'{key}' must be a positive integer")
try:
number = int(value)
except (TypeError, ValueError) as exc:
raise ReMeConfigError(f"'{key}' must be a positive integer") from exc
if number <= 0 or (isinstance(value, float) and not value.is_integer()):
raise ReMeConfigError(f"'{key}' must be a positive integer")
return number
def parse_config(values: dict[str, Any], *, hermes_home: str | Path) -> ReMeConfig:
"""Validate and normalize an already loaded configuration mapping."""
del hermes_home # Reserved for future profile-relative settings.
defaults = ReMeConfig()
mode = str(values.get("mode", defaults.mode) or defaults.mode).strip().lower()
if mode not in VALID_MODES:
raise ReMeConfigError("'mode' must be either 'http' or 'embedded'")
endpoint = str(values.get("endpoint", defaults.endpoint) or "").strip().rstrip("/")
if mode == "http":
try:
endpoint = normalize_http_endpoint(endpoint)
except ValueError as exc:
raise ReMeConfigError(
"ReMe endpoint must be an absolute http(s) URL",
) from exc
raw_workspace = str(
values.get("workspace_dir", defaults.workspace_dir) or "",
).strip()
if mode == "embedded" and not raw_workspace:
raise ReMeConfigError("'workspace_dir' is required in embedded mode")
workspace_dir = str(Path(raw_workspace).expanduser().absolute()) if raw_workspace else ""
reme_config = str(values.get("reme_config", defaults.reme_config) or "").strip()
if mode == "embedded" and not reme_config:
raise ReMeConfigError("'reme_config' cannot be empty in embedded mode")
return ReMeConfig(
mode=mode,
endpoint=endpoint or defaults.endpoint,
workspace_dir=workspace_dir,
reme_config=reme_config or defaults.reme_config,
request_timeout=_positive_float(
values.get("request_timeout"),
"request_timeout",
defaults.request_timeout,
),
recall_timeout=_positive_float(
values.get("recall_timeout"),
"recall_timeout",
defaults.recall_timeout,
),
health_timeout=_positive_float(
values.get("health_timeout"),
"health_timeout",
defaults.health_timeout,
),
health_retry_seconds=_positive_float(
values.get("health_retry_seconds"),
"health_retry_seconds",
defaults.health_retry_seconds,
),
shutdown_timeout=_positive_float(
values.get("shutdown_timeout"),
"shutdown_timeout",
defaults.shutdown_timeout,
),
recall_limit=_positive_int(
values.get("recall_limit"),
"recall_limit",
defaults.recall_limit,
),
)
def load_config(hermes_home: str | Path | None = None) -> ReMeConfig:
"""Load current config, inheriting omitted fields from the legacy file."""
home = Path(hermes_home).expanduser() if hermes_home is not None else _default_hermes_home()
return parse_config(_read_config_values(home), hermes_home=home)
def save_config(values: dict[str, Any], hermes_home: str | Path) -> ReMeConfig:
"""Validate and atomically write config in the current Hermes layout."""
home = Path(hermes_home).expanduser()
current = config_path(home)
merged = _read_config_values(home)
merged.update(
{key: value for key, value in dict(values or {}).items() if value is not None},
)
validated = parse_config(merged, hermes_home=home)
current.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(prefix=f".{current.name}.", dir=current.parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(
asdict(validated),
handle,
ensure_ascii=False,
indent=2,
sort_keys=True,
)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.chmod(tmp_name, 0o600)
os.replace(tmp_name, current)
finally:
if os.path.exists(tmp_name):
os.unlink(tmp_name)
return validated