mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-14 23:21:04 +00:00
* 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
208 lines
7 KiB
Python
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
|