mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
feat(memory): profile lifecycle and config-storage seams for memory providers
A catalog memory provider (Honcho today) has no way to own its per-profile setup or its dashboard config file, so core carries that code for it: profile create/rename edit Honcho's host blocks and the dashboard has a Honcho-only storage kind that imports the plugin's private modules. These seams let a provider do that work itself; core's Honcho code is untouched here and is removed in a follow-up once the plugin implements them. - MemoryProvider.on_profile_created(profile_name, hermes_home) / on_profile_renamed(old, new, hermes_home): called from create_profile (CLI, dashboard, Desktop/TUI) and rename_profile for the provider the profile's effective config selects, with that profile's home and its own .env secrets bound. A failing hook or unreadable config is logged and never fails the create or rename. No-ops by default. - storage=STORAGE_PROVIDER in config_schema.py, with read_config_fields(hermes_home) / write_config_fields(values, hermes_home): the provider owns its file layout. Core keeps value coercion, placeholders, write-only secrets, the memory.provider switch and the 400 on a refused write, and saves secrets to .env only after the provider accepted them. _record_profile_rename moves to profile_identity.py (profiles.py line budget).
This commit is contained in:
1 parent
d914e23344
commit
e090092285
11 files changed
+448
-25
No files matched your search
@@ -223,3 +223,30 @@ class MemoryProvider(ABC):
|
||||
"""Absolute paths of provider state OUTSIDE HERMES_HOME for ``hermes backup``/``import``
|
||||
(paths outside the home dir are skipped). MUST work without ``initialize()`` or network."""
|
||||
return []
|
||||
|
||||
# -- Profile lifecycle (uninitialized instance; that profile's home and secrets bound; errors are
|
||||
# logged). Keep them fast: the dashboard and Desktop run them inside the create/rename request,
|
||||
# so put network work (creating a remote peer) on a background thread. --
|
||||
|
||||
def on_profile_created(self, profile_name: str, hermes_home: str) -> None:
|
||||
"""A profile whose config selects this provider was just created by ``create_profile`` (CLI,
|
||||
dashboard, Desktop/TUI; a clone carries ``memory.provider``). Not called for profile import.
|
||||
Set up per-profile provider state here (a peer, a host entry)."""
|
||||
|
||||
def on_profile_renamed(self, old_name: str, new_name: str, hermes_home: str) -> None:
|
||||
"""A profile whose config selects this provider was renamed; ``hermes_home`` is its new
|
||||
directory. Re-key anything the provider stores under the old profile name."""
|
||||
|
||||
# -- Dashboard config panel for a ``config_schema.py`` declaring ``storage=STORAGE_PROVIDER`` --
|
||||
|
||||
def read_config_fields(self, hermes_home: str) -> dict[str, Any]:
|
||||
"""``{"values": {field key: stored native value}, "placeholders": {field key: text}}``. A key
|
||||
absent from ``values`` reads as unset; a placeholder shows what a blank field resolves to.
|
||||
A stored secret may be included: it only marks the field as set and is never sent out."""
|
||||
return {"values": {}, "placeholders": {}}
|
||||
|
||||
def write_config_fields(self, values: dict[str, Any], hermes_home: str) -> None:
|
||||
"""Persist the submitted fields (only those present): natives already coerced, ``None`` clears a
|
||||
key, non-blank secrets included (core saves each to ``.env`` under its ``env_key`` once this
|
||||
returns). Raise ``ValueError`` to refuse the save (the dashboard shows it as a 400)."""
|
||||
raise NotImplementedError(f"memory provider {self.name} declares storage=provider but cannot write it")
|
||||
@@ -237,3 +237,19 @@ def _migrate_profile_identity(old_canon: str, new_canon: str, live_mux: bool) ->
|
||||
with contextlib.suppress(Exception):
|
||||
release_or_close(db)
|
||||
return migrated
|
||||
|
||||
|
||||
def _record_profile_rename(new_dir: Path, old_canon: str) -> None:
|
||||
"""Append ``old_canon`` to the renamed profile's ``previous_names`` history.
|
||||
Best-effort: never raises, so a metadata write failure cannot fail the rename.
|
||||
|
||||
Only reached for a real slug change — ``rename_profile`` returns early for the
|
||||
default profile (display-name only) and refuses ``old == new`` (target exists)."""
|
||||
from hermes_cli import profiles as _profiles
|
||||
try:
|
||||
history = _profiles.read_profile_meta(new_dir).get("previous_names") or []
|
||||
if old_canon not in history:
|
||||
history = [*history, old_canon]
|
||||
_profiles.write_profile_meta(new_dir, previous_names=history)
|
||||
except Exception as exc: # unwritable / corrupt profile.yaml — history is advisory
|
||||
_profiles.logger.debug("profile rename: could not record previous name %r in %s: %s", old_canon, new_dir, exc)
|
||||
@@ -10,11 +10,16 @@ tree, so a hook the plugin must implement could not fix the reported case.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Callable, Optional
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# A provider name is a bare directory/file stem; anything else (path separators, ``..``, spaces)
|
||||
# would let a hand-edited config.yaml aim the copy outside the source profile.
|
||||
@@ -74,6 +79,39 @@ def _load_config_dict(profile_dir: Path) -> Optional[dict]:
|
||||
return data if isinstance(data, dict) else None
|
||||
|
||||
|
||||
def notify_memory_provider(profile_dir: Path, call: Callable[[MemoryProvider], None]) -> None:
|
||||
"""Run ``call`` on the memory provider *profile_dir*'s config selects, with that profile's home AND
|
||||
its own secrets bound (a profile lifecycle hook): ``get_secret`` reads the new profile's ``.env``
|
||||
first (outside multiplexing, a key it lacks falls back to the process env, as at runtime). No
|
||||
provider selected or installed: nothing runs. Best-effort: a failing
|
||||
provider, or an unreadable config, is logged and never fails the create/rename that called it
|
||||
(the directory is already published)."""
|
||||
from agent.secret_scope import build_profile_secret_scope, reset_secret_scope, set_secret_scope
|
||||
from hermes_cli.config_backend import ConfigBackendUnavailable
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
from plugins.memory import load_memory_provider
|
||||
|
||||
name = None
|
||||
home_token = set_hermes_home_override(str(profile_dir))
|
||||
secret_token = None
|
||||
try:
|
||||
# Secrets first: the effective config (managed scope, ``${VAR}`` refs) expands through them.
|
||||
secret_token = set_secret_scope(build_profile_secret_scope(profile_dir), profile_home=str(profile_dir))
|
||||
from hermes_cli.config import load_config_readonly
|
||||
name = active_memory_provider(load_config_readonly())
|
||||
if not name:
|
||||
return
|
||||
provider = load_memory_provider(name, register_skills=False)
|
||||
if provider is not None:
|
||||
call(provider)
|
||||
except (Exception, ConfigBackendUnavailable): # the backend error is a SystemExit on purpose
|
||||
logger.warning("memory provider %r: profile hook failed for %s", name, profile_dir, exc_info=True)
|
||||
finally:
|
||||
if secret_token is not None:
|
||||
reset_secret_scope(secret_token)
|
||||
reset_hermes_home_override(home_token)
|
||||
|
||||
|
||||
def cloned_memory_provider(profile_dir: Path) -> Optional[str]:
|
||||
"""Name of the external provider whose config *profile_dir* now carries, for the CLI notice."""
|
||||
provider = active_memory_provider(_load_config_dict(profile_dir))
|
||||
|
||||
+6
-15
@@ -1411,6 +1411,8 @@ def create_profile(
|
||||
discard_unpublished_vault_key(clone_key)
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
raise
|
||||
from hermes_cli.profile_memory_config import notify_memory_provider
|
||||
notify_memory_provider(profile_dir, lambda p: p.on_profile_created(canon, str(profile_dir)))
|
||||
|
||||
# Inside a container under s6, register the gateway as a runtime s6 service so
|
||||
# `hermes -p <profile> gateway start` supervises via `s6-svc -u` instead of a bare
|
||||
@@ -2238,21 +2240,6 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
|
||||
# Rename
|
||||
|
||||
def _record_profile_rename(new_dir: Path, old_canon: str) -> None:
|
||||
"""Append ``old_canon`` to the renamed profile's ``previous_names`` history.
|
||||
Best-effort: never raises, so a metadata write failure cannot fail the rename.
|
||||
|
||||
Only reached for a real slug change — ``rename_profile`` returns early for the
|
||||
default profile (display-name only) and refuses ``old == new`` (target exists)."""
|
||||
try:
|
||||
history = read_profile_meta(new_dir).get("previous_names") or []
|
||||
if old_canon not in history:
|
||||
history = [*history, old_canon]
|
||||
write_profile_meta(new_dir, previous_names=history)
|
||||
except Exception as exc: # unwritable / corrupt profile.yaml — history is advisory
|
||||
logger.debug("profile rename: could not record previous name %r in %s: %s", old_canon, new_dir, exc)
|
||||
|
||||
|
||||
def _refuse_rename_without_file_tooling(old_canon: str) -> None:
|
||||
"""A remote config backend keys each profile's settings by its canonical name, and the plane
|
||||
has no rename: renaming the local home would leave the settings under the old name and read
|
||||
@@ -2338,11 +2325,15 @@ def rename_profile(old_name: str, new_name: str) -> Path:
|
||||
# 2b. Record the rename so Bot Mode group chats can re-link persisted
|
||||
# member descriptors to the new slug (#110200). Best-effort: a metadata
|
||||
# write failure must never fail the rename itself.
|
||||
from hermes_cli.profile_identity import _record_profile_rename
|
||||
_record_profile_rename(new_dir, old_canon)
|
||||
|
||||
# 3. Update profile-scoped Honcho host blocks, preserving aiPeer identity
|
||||
from hermes_cli.profiles_honcho import migrate_honcho_profile_host
|
||||
migrate_honcho_profile_host(old_canon, new_canon, new_dir)
|
||||
# 3b. Let the profile's memory provider re-key what it stores under the old name.
|
||||
from hermes_cli.profile_memory_config import notify_memory_provider
|
||||
notify_memory_provider(new_dir, lambda p: p.on_profile_renamed(old_canon, new_canon, str(new_dir)))
|
||||
|
||||
# 4. Update wrapper script
|
||||
remove_wrapper_script(old_canon)
|
||||
|
||||
@@ -24,7 +24,7 @@ from hermes_cli.web_server_memory import (
|
||||
from hermes_cli.web_models import MemoryProviderConfigUpdate, MemoryProviderSetupRequest
|
||||
from hermes_cli.web_routers._common import _CONFIG_MUTATION_LOCK, scoped_to_thread
|
||||
from plugins.memory.config_schema import (
|
||||
STORAGE_HONCHO_HOST_BLOCK, ProviderConfigSchema, ProviderField, get_provider_config_schema,
|
||||
STORAGE_HONCHO_HOST_BLOCK, STORAGE_PROVIDER, ProviderConfigSchema, ProviderField, get_provider_config_schema,
|
||||
)
|
||||
|
||||
_log = logging.getLogger("hermes_cli.web_server")
|
||||
@@ -131,6 +131,14 @@ def _honcho_resolvers(name: str):
|
||||
return client.resolve_active_host, client.resolve_config_path, client._host_block
|
||||
|
||||
|
||||
def _provider_instance(provider: ProviderConfigSchema):
|
||||
"""The loaded provider behind a ``STORAGE_PROVIDER`` schema (it owns its config layout)."""
|
||||
instance = _load_memory_provider(provider.name, register_skills=False) # a panel read never registers
|
||||
if instance is None:
|
||||
raise _unknown_provider(provider.name)
|
||||
return instance
|
||||
|
||||
|
||||
def _save_submitted_secrets(provider: ProviderConfigSchema, values: dict[str, str]) -> list:
|
||||
"""Persist each non-blank secret submission to the env store (when the field has an
|
||||
``env_key``); return the ``(field, submitted)`` pairs for backend-specific handling."""
|
||||
@@ -152,11 +160,12 @@ def _apply_field_values(provider: ProviderConfigSchema, values: dict[str, str],
|
||||
clobbers fields owned by another surface. ``_UNSET`` clears the key (and
|
||||
its aliases) so it falls back to the host/default mapping.
|
||||
"""
|
||||
natives = _submitted_natives(provider, values)
|
||||
for field in provider.fields:
|
||||
if field.is_secret or field.key not in values:
|
||||
if field.key not in natives:
|
||||
continue
|
||||
target = target_for(field)
|
||||
coerced = _coerce_field_value(field, values[field.key])
|
||||
coerced = natives[field.key]
|
||||
if coerced is _UNSET:
|
||||
for key in (field.key, *field.aliases):
|
||||
target.pop(key, None)
|
||||
@@ -171,6 +180,23 @@ def _write_json_0600(path: Path, data: dict[str, Any]) -> None:
|
||||
atomic_json_write(path, data, mode=0o600)
|
||||
|
||||
|
||||
def _submitted_natives(provider: ProviderConfigSchema, values: dict[str, str]) -> dict[str, Any]:
|
||||
"""The submitted non-secret fields as natives, ``_UNSET`` for a cleared one (only keys present in
|
||||
``values``, so a partial save never clobbers fields owned by another surface)."""
|
||||
return {field.key: _coerce_field_value(field, values[field.key])
|
||||
for field in provider.fields if not field.is_secret and field.key in values}
|
||||
|
||||
|
||||
def _write_provider_own(provider: ProviderConfigSchema, values: dict[str, str]) -> None:
|
||||
"""Hand the submitted fields to the provider's ``write_config_fields`` (natives, ``None`` = clear,
|
||||
non-blank secrets included); secrets reach ``.env`` only once the provider accepted the write."""
|
||||
secrets = {field.key: (values.get(field.key) or "").strip() for field in provider.fields if field.is_secret}
|
||||
fields = {k: v for k, v in secrets.items() if v}
|
||||
fields.update({k: None if v is _UNSET else v for k, v in _submitted_natives(provider, values).items()})
|
||||
_provider_instance(provider).write_config_fields(fields, str(get_hermes_home()))
|
||||
_save_submitted_secrets(provider, values)
|
||||
|
||||
|
||||
def _write_provider_flat(provider: ProviderConfigSchema, values: dict[str, str]) -> None:
|
||||
existing = _read_flat_json(provider)
|
||||
_save_submitted_secrets(provider, values)
|
||||
@@ -248,7 +274,17 @@ def _declared_field_is_set(field: ProviderField, sources: tuple, env: dict[str,
|
||||
def _declared_provider_payload(provider: ProviderConfigSchema) -> dict[str, Any]:
|
||||
env = load_env()
|
||||
is_honcho = provider.storage == STORAGE_HONCHO_HOST_BLOCK
|
||||
if is_honcho:
|
||||
# A provider-owned layout reports presence (a stored False/0 is set), like the host block.
|
||||
own = provider.storage == STORAGE_PROVIDER
|
||||
placeholders: dict[str, Any] = {}
|
||||
if own:
|
||||
stored = _provider_instance(provider).read_config_fields(str(get_hermes_home())) or {}
|
||||
host, data = "", dict(stored.get("values") or {})
|
||||
placeholders = dict(stored.get("placeholders") or {})
|
||||
|
||||
def sources_for(field: ProviderField) -> tuple:
|
||||
return (data,)
|
||||
elif is_honcho:
|
||||
resolve_active_host, resolve_config_path, host_block_of = _honcho_resolvers(provider.name)
|
||||
host = resolve_active_host()
|
||||
raw = _read_json_dict(resolve_config_path(), "Honcho config")
|
||||
@@ -267,6 +303,8 @@ def _declared_provider_payload(provider: ProviderConfigSchema) -> dict[str, Any]
|
||||
entry = {k: getattr(field, k) for k in ("key", "label", "kind", "description", "info", "placeholder", "inline", "group")}
|
||||
entry["options"] = [{"value": o.value, "label": o.label, "description": o.description} for o in field.options]
|
||||
sources = sources_for(field)
|
||||
if not field.placeholder and placeholders.get(field.key):
|
||||
entry["placeholder"] = str(placeholders[field.key])
|
||||
if field.is_secret:
|
||||
entry["value"] = "" # secrets are write-only over the API
|
||||
entry["is_set"] = _declared_field_is_set(field, sources, env)
|
||||
@@ -281,7 +319,7 @@ def _declared_provider_payload(provider: ProviderConfigSchema) -> dict[str, Any]
|
||||
value = field.default
|
||||
entry["value"] = value
|
||||
# Presence, not truthiness — a stored False/0 is still "set".
|
||||
entry["is_set"] = native is not None if is_honcho else bool(value)
|
||||
entry["is_set"] = native is not None if (is_honcho or own) else bool(value)
|
||||
fields.append(entry)
|
||||
return {"name": provider.name, "label": provider.label, "docs_url": provider.docs_url, "fields": fields}
|
||||
|
||||
@@ -306,7 +344,8 @@ def _memory_section(config: dict[str, Any]) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _update_memory_provider_config(provider: ProviderConfigSchema, values: dict[str, str]) -> None:
|
||||
writer = _write_provider_honcho if provider.storage == STORAGE_HONCHO_HOST_BLOCK else _write_provider_flat
|
||||
writer = {STORAGE_PROVIDER: _write_provider_own,
|
||||
STORAGE_HONCHO_HOST_BLOCK: _write_provider_honcho}.get(provider.storage, _write_provider_flat)
|
||||
writer(provider, values)
|
||||
with _CONFIG_MUTATION_LOCK: # RMW span vs. the dashboard's config autosave
|
||||
config = load_config()
|
||||
|
||||
@@ -19,11 +19,11 @@ def _normalize_memory_provider_name(name: Any) -> str:
|
||||
return "" if is_core_memory_provider(provider) else provider
|
||||
|
||||
|
||||
def _load_memory_provider(name: str):
|
||||
def _load_memory_provider(name: str, *, register_skills: Optional[bool] = None):
|
||||
try:
|
||||
from plugins.memory import load_memory_provider
|
||||
|
||||
return load_memory_provider(name)
|
||||
return load_memory_provider(name, register_skills=register_skills)
|
||||
except Exception:
|
||||
_log.debug("Failed to load memory provider %s", name, exc_info=True)
|
||||
return None
|
||||
|
||||
+4
-1
@@ -22,7 +22,10 @@ command. A hook with no concrete consumer is speculative infrastructure and is r
|
||||
`hermes_cli/memory_provider_migration.py` for homes still configured for them. Host-side code a
|
||||
catalog provider still relies on (the `honcho_host_block` config storage kind, profile clone /
|
||||
rename / update-sync hooks) resolves the provider's modules through
|
||||
`plugins.memory.import_provider_module`, never a `plugins.memory.<name>` import. New
|
||||
`plugins.memory.import_provider_module`, never a `plugins.memory.<name>` import. Generic seams
|
||||
supersede that code (it is removed once the catalog provider implements them): a provider owns its profile setup through `MemoryProvider.on_profile_created` /
|
||||
`on_profile_renamed` and its dashboard config file through `storage=STORAGE_PROVIDER` +
|
||||
`read_config_fields` / `write_config_fields`; new provider-specific host code is refused. New
|
||||
backends ship as standalone repos implementing the same `MemoryProvider` ABC, discovered through
|
||||
the same path, integrated via `hermes memory setup` / `post_setup()`.
|
||||
- **Other features that leave core** (Home Assistant platform + toolset, Oct 2026 →
|
||||
|
||||
@@ -25,6 +25,10 @@ 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"
|
||||
# The provider's read_config_fields / write_config_fields own the file layout
|
||||
# (agent.memory_provider.MemoryProvider); loading them loads the provider itself (in the plugin
|
||||
# host when ``plugins.isolation`` is ``host``), and its writer also receives submitted secrets.
|
||||
STORAGE_PROVIDER = "provider"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Profile create/rename reach the memory provider the profile selects (agent.memory_provider's
|
||||
on_profile_created / on_profile_renamed), best-effort, with that profile's home and secrets bound."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def profile_env(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
||||
default_home = tmp_path / ".hermes"
|
||||
default_home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(default_home))
|
||||
return default_home
|
||||
|
||||
|
||||
class _Stub(MemoryProvider):
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
def initialize(self, session_id, **kwargs):
|
||||
pass
|
||||
|
||||
def get_tool_schemas(self):
|
||||
return []
|
||||
|
||||
|
||||
def _select(home: Path, name: str) -> None:
|
||||
(home / "config.yaml").write_text(f"memory:\n provider: {name}\n", encoding="utf-8")
|
||||
|
||||
|
||||
def test_create_and_rename_call_the_selected_providers_hooks_with_its_home_bound(profile_env, monkeypatch):
|
||||
import plugins.memory as memory_pkg
|
||||
from hermes_cli.profiles import create_profile, rename_profile
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
calls = []
|
||||
|
||||
class Recorder(_Stub):
|
||||
name = "recorder"
|
||||
|
||||
def on_profile_created(self, profile_name, hermes_home):
|
||||
calls.append(("created", profile_name, hermes_home, str(get_hermes_home())))
|
||||
|
||||
def on_profile_renamed(self, old_name, new_name, hermes_home):
|
||||
calls.append(("renamed", old_name, new_name, hermes_home, str(get_hermes_home())))
|
||||
|
||||
monkeypatch.setattr(memory_pkg, "load_memory_provider",
|
||||
lambda name, **kw: Recorder() if name == "recorder" else None)
|
||||
_select(profile_env, "recorder")
|
||||
create_profile("plain", no_alias=True) # fresh: no memory.provider, so no hook
|
||||
created = create_profile("ssi_health", clone_config=True, no_alias=True) # the clone carries it
|
||||
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"):
|
||||
renamed = rename_profile("ssi_health", "heimdall")
|
||||
|
||||
assert calls == [
|
||||
("created", "ssi_health", str(created), str(created)),
|
||||
("renamed", "ssi_health", "heimdall", str(renamed), str(renamed)),
|
||||
]
|
||||
|
||||
|
||||
def test_the_hook_reads_the_new_profiles_secrets_not_the_callers(profile_env, monkeypatch):
|
||||
import plugins.memory as memory_pkg
|
||||
from agent.secret_scope import get_secret
|
||||
from hermes_cli.profiles import create_profile
|
||||
|
||||
seen = []
|
||||
|
||||
class Reader(_Stub):
|
||||
name = "reader"
|
||||
|
||||
def on_profile_created(self, profile_name, hermes_home):
|
||||
seen.append(get_secret("PROBE_PROFILE_KEY", "unset"))
|
||||
|
||||
monkeypatch.setattr(memory_pkg, "load_memory_provider", lambda name, **kw: Reader())
|
||||
monkeypatch.setenv("PROBE_PROFILE_KEY", "caller-value")
|
||||
_select(profile_env, "reader")
|
||||
(profile_env / ".env").write_text("PROBE_PROFILE_KEY=clone-value\n", encoding="utf-8")
|
||||
create_profile("work", clone_config=True, no_alias=True) # --clone copies the .env
|
||||
assert seen == ["clone-value"]
|
||||
|
||||
|
||||
def test_the_hook_follows_the_effective_selection_including_env_refs(profile_env, monkeypatch):
|
||||
"""``memory.provider: ${VAR}`` resolves through the new profile's own .env, like at runtime."""
|
||||
import plugins.memory as memory_pkg
|
||||
from hermes_cli.profiles import create_profile
|
||||
|
||||
loaded = []
|
||||
monkeypatch.setattr(memory_pkg, "load_memory_provider", lambda name, **kw: loaded.append(name))
|
||||
(profile_env / "config.yaml").write_text("memory:\n provider: ${PROBE_MEMORY_PROVIDER}\n", encoding="utf-8")
|
||||
(profile_env / ".env").write_text("PROBE_MEMORY_PROVIDER=recorder\n", encoding="utf-8")
|
||||
create_profile("work", clone_config=True, no_alias=True)
|
||||
assert loaded == ["recorder"]
|
||||
|
||||
|
||||
def test_an_unreadable_config_backend_never_fails_create(profile_env, monkeypatch, caplog):
|
||||
from hermes_cli.config_backend import ConfigBackendUnavailable
|
||||
from hermes_cli.profiles import create_profile
|
||||
|
||||
def boom(*_a, **_k):
|
||||
raise ConfigBackendUnavailable("backend down")
|
||||
|
||||
import hermes_cli.config as config_mod
|
||||
monkeypatch.setattr(config_mod, "load_config_readonly", boom)
|
||||
with caplog.at_level("WARNING", logger="hermes_cli.profile_memory_config"):
|
||||
assert create_profile("work", no_alias=True).is_dir()
|
||||
assert "profile hook failed" in caplog.text
|
||||
|
||||
|
||||
def test_a_failing_provider_hook_never_fails_create_or_rename(profile_env, monkeypatch):
|
||||
import plugins.memory as memory_pkg
|
||||
from hermes_cli.profiles import create_profile, rename_profile
|
||||
|
||||
class Broken(_Stub):
|
||||
name = "broken"
|
||||
|
||||
def on_profile_created(self, profile_name, hermes_home):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
def on_profile_renamed(self, old_name, new_name, hermes_home):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr(memory_pkg, "load_memory_provider", lambda name, **kw: Broken())
|
||||
_select(profile_env, "broken")
|
||||
create_profile("work", clone_config=True, no_alias=True)
|
||||
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"):
|
||||
assert rename_profile("work", "play").is_dir()
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Dashboard config panel for ``storage=STORAGE_PROVIDER``: the provider's read_config_fields /
|
||||
write_config_fields own the file layout (here a host-block file like a catalog provider keeps);
|
||||
core coerces values, keeps secrets write-only, and never opens the file itself."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
_INIT = """
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
|
||||
_ROOT_KEYS = {"baseUrl"}
|
||||
|
||||
|
||||
def _read(home):
|
||||
path = Path(home) / "ownprov.json"
|
||||
return path, (json.loads(path.read_text(encoding="utf-8")) if path.exists() else {})
|
||||
|
||||
|
||||
class OwnProv(MemoryProvider):
|
||||
name = "ownprov"
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
def initialize(self, session_id, **kwargs):
|
||||
pass
|
||||
|
||||
def get_tool_schemas(self):
|
||||
return []
|
||||
|
||||
def read_config_fields(self, hermes_home):
|
||||
_path, cfg = _read(hermes_home)
|
||||
block = (cfg.get("hosts") or {}).get("hermes") or {}
|
||||
values = {**{k: v for k, v in cfg.items() if k in _ROOT_KEYS}, **block}
|
||||
return {"values": values, "placeholders": {"workspace": "hermes", "aiPeer": "hermes"}}
|
||||
|
||||
def write_config_fields(self, values, hermes_home):
|
||||
path, cfg = _read(hermes_home) # a file that does not parse raises ValueError: no write
|
||||
block = cfg.setdefault("hosts", {}).setdefault("hermes", {})
|
||||
for key, value in values.items():
|
||||
target = cfg if key in _ROOT_KEYS else block
|
||||
if value is None:
|
||||
target.pop(key, None)
|
||||
else:
|
||||
target[key] = value
|
||||
path.write_text(json.dumps(cfg), encoding="utf-8")
|
||||
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_memory_provider(OwnProv())
|
||||
"""
|
||||
|
||||
_SCHEMA = """
|
||||
from plugins.memory.config_schema import (
|
||||
KIND_BOOL, KIND_SECRET, KIND_TEXT, STORAGE_PROVIDER, ProviderConfigSchema, ProviderField,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA = ProviderConfigSchema(
|
||||
name="ownprov",
|
||||
label="Provider-owned storage",
|
||||
storage=STORAGE_PROVIDER,
|
||||
fields=(
|
||||
ProviderField(key="apiKey", label="API key", kind=KIND_SECRET, description="", env_key="OWNPROV_API_KEY"),
|
||||
ProviderField(key="baseUrl", label="Base URL", kind=KIND_TEXT, description=""),
|
||||
ProviderField(key="workspace", label="Workspace", kind=KIND_TEXT, description=""),
|
||||
ProviderField(key="saveMessages", label="Save messages", kind=KIND_BOOL, description="", default="true"),
|
||||
),
|
||||
)
|
||||
"""
|
||||
|
||||
_URL = "/api/memory/providers/ownprov/config?surface=declared"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(monkeypatch, _isolate_hermes_home):
|
||||
try:
|
||||
from starlette.testclient import TestClient
|
||||
except ImportError:
|
||||
pytest.skip("fastapi/starlette not installed")
|
||||
import hermes_state
|
||||
from hermes_constants import get_hermes_home
|
||||
from hermes_cli.web_server import _SESSION_HEADER_NAME, _SESSION_TOKEN, app
|
||||
|
||||
monkeypatch.setattr(hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db")
|
||||
plugin_dir = get_hermes_home() / "plugins" / "ownprov"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "__init__.py").write_text(_INIT, encoding="utf-8")
|
||||
(plugin_dir / "config_schema.py").write_text(_SCHEMA, encoding="utf-8")
|
||||
test_client = TestClient(app)
|
||||
test_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
return test_client
|
||||
|
||||
|
||||
def _config_path():
|
||||
from hermes_constants import get_hermes_home
|
||||
return get_hermes_home() / "ownprov.json"
|
||||
|
||||
|
||||
def test_put_writes_through_the_provider_with_natives_and_saves_the_secret(client):
|
||||
from hermes_cli.config import load_config, load_env
|
||||
|
||||
resp = client.put(_URL, json={"values": {
|
||||
"apiKey": "own-test-key", "baseUrl": "https://own.example.dev", "workspace": "myws", "saveMessages": "false",
|
||||
}})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert load_config()["memory"]["provider"] == "ownprov"
|
||||
assert load_env()["OWNPROV_API_KEY"] == "own-test-key"
|
||||
cfg = json.loads(_config_path().read_text(encoding="utf-8"))
|
||||
assert cfg["baseUrl"] == "https://own.example.dev"
|
||||
assert cfg["hosts"]["hermes"] == {"apiKey": "own-test-key", "workspace": "myws", "saveMessages": False}
|
||||
|
||||
|
||||
def test_get_keeps_secrets_write_only_and_shows_placeholders_and_presence(client):
|
||||
client.put(_URL, json={"values": {"apiKey": "secret-value", "saveMessages": "false"}})
|
||||
|
||||
resp = client.get(_URL)
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert "secret-value" not in json.dumps(resp.json())
|
||||
fields = {field["key"]: field for field in resp.json()["fields"]}
|
||||
assert fields["apiKey"]["is_set"] is True and fields["apiKey"]["value"] == ""
|
||||
assert fields["workspace"]["placeholder"] == "hermes" and fields["workspace"]["is_set"] is False
|
||||
assert fields["saveMessages"]["is_set"] is True # a stored False is still set
|
||||
assert fields["saveMessages"]["value"] == "false"
|
||||
|
||||
|
||||
def test_a_blank_field_clears_the_key(client):
|
||||
client.put(_URL, json={"values": {"workspace": "myws"}})
|
||||
client.put(_URL, json={"values": {"workspace": ""}})
|
||||
assert "workspace" not in json.loads(_config_path().read_text(encoding="utf-8"))["hosts"]["hermes"]
|
||||
|
||||
|
||||
def test_a_refused_write_is_a_400_with_no_secret_saved_and_no_switch(client):
|
||||
from hermes_cli.config import load_config, load_env
|
||||
|
||||
_config_path().write_text("{not json", encoding="utf-8")
|
||||
|
||||
resp = client.put(_URL, json={"values": {"workspace": "myws", "apiKey": "own-new-key"}})
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert _config_path().read_text(encoding="utf-8") == "{not json"
|
||||
assert (load_config().get("memory") or {}).get("provider") != "ownprov"
|
||||
assert "OWNPROV_API_KEY" not in load_env()
|
||||
@@ -178,6 +178,12 @@ workspace; absent or empty cwd remains unpinned.
|
||||
| `on_pre_compress(messages)` | Before context compression | Save insights before discard |
|
||||
| `on_memory_write(action, target, content, metadata=None)` | Built-in memory writes | Mirror to your backend |
|
||||
| `shutdown()` | Process exit | Clean up connections |
|
||||
| `on_profile_created(profile_name, hermes_home)` | A profile selecting your provider was created (a clone carries `memory.provider`), from the CLI, dashboard or Desktop | Per-profile state: a peer, a host entry |
|
||||
| `on_profile_renamed(old_name, new_name, hermes_home)` | That profile was renamed (`hermes_home` is the new directory) | Re-key state stored under the old name |
|
||||
|
||||
The two profile hooks run on an uninitialized instance with that profile's home and its own `.env`
|
||||
secrets bound (a key that `.env` lacks falls back to the process environment, as at runtime). An exception is logged and never fails the create or rename. They run inside the
|
||||
create/rename request on every surface, so do network work on a background thread.
|
||||
|
||||
For native `replace` and `remove`, `metadata["previous_content"]` contains the full
|
||||
entry selected under the native-store lock. Notifications are emitted only after
|
||||
@@ -339,6 +345,25 @@ Fields with `secret: True` and `env_var` go to `.env`. Non-secret fields are pas
|
||||
Every field in `get_config_schema()` is prompted during `hermes memory setup`. Providers with many options should keep the schema minimal — only include fields the user **must** configure (API key, required credentials). Document optional settings in a config file reference (e.g. `$HERMES_HOME/myprovider.json`) rather than prompting for them all during setup. This keeps the setup wizard fast while still supporting advanced configuration. See the [Supermemory provider](https://github.com/supermemoryai/hermes-supermemory) (a plugin catalog entry) for an example — it only prompts for the API key; all other options live in `supermemory.json`.
|
||||
:::
|
||||
|
||||
### Provider-owned storage
|
||||
|
||||
`config_schema.py` defaults to `storage=STORAGE_FLAT_JSON`: core reads and writes
|
||||
`$HERMES_HOME/<name>/config.json` for the panel. A provider whose config has its own layout
|
||||
(several profiles in one file, OAuth tokens beside settings) declares `storage=STORAGE_PROVIDER`
|
||||
and implements two methods; core never opens the file:
|
||||
|
||||
```python
|
||||
def read_config_fields(self, hermes_home: str) -> dict:
|
||||
# Stored natives by field key (absent = unset), plus what a blank field resolves to.
|
||||
return {"values": {"workspace": "ws1"}, "placeholders": {"aiPeer": "hermes_coder"}}
|
||||
|
||||
def write_config_fields(self, values: dict, hermes_home: str) -> None:
|
||||
# Only submitted keys; None clears one. Non-blank secrets arrive here too; core saves them to
|
||||
# .env under env_key only after this returns. Raise a plain ValueError to refuse the save
|
||||
# (shown as a 400), e.g. when your file does not parse.
|
||||
...
|
||||
```
|
||||
|
||||
## Save Config
|
||||
|
||||
```python
|
||||
|
||||
Reference in new issue
Block a user