fix(hermes): preserve profile context and reset invalid provider state (#554)
Some checks failed
CI / Python packages / Build and verify distributions (push) Has been cancelled
CI / Python quality / GitHub Actions (push) Has been cancelled
CI / Python quality / Pre-commit (push) Has been cancelled
CI / Python tests / Unit Tests - py3.11 (push) Has been cancelled
CI / Python tests / Unit Tests - py3.12 (push) Has been cancelled
CI / Python tests / Unit Tests - py3.13 (push) Has been cancelled
CI / Python tests / Unit Tests - py3.14 (push) Has been cancelled
CI / Windows / CLI smoke - py3.11 (push) Has been cancelled
Deploy / Documentation / Build documentation (push) Has been cancelled
Security / CodeQL / Analyze javascript-typescript (push) Has been cancelled
Security / CodeQL / Analyze python (push) Has been cancelled
Deploy / Documentation / deploy (push) Has been cancelled

* Fix Hermes provider profile thread compatibility and reinitialization

* docs(hermes): clarify compatibility checks were manual
This commit is contained in:
jinliyl 2026-09-16 16:01:15 +08:00 • committed by GitHub
parent 9ebe17a89d
commit 5231f3970c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 229 additions and 18 deletions

View file

@ -49,6 +49,9 @@ users retain control of the durable files.
## 📰 Latest Updates
- [2026.09] - **[Hermes Agent memory provider](integrations/hermes_agent/README.md) available**: choose HTTP or embedded
mode for automatic recall before model calls and asynchronous `auto_memory` after completed turns. The integration
supports Hermes Agent 0.21+ and includes profile-aware background work.
- [2026.09] - **[OpenClaw plugin](https://reme.agentscope.io/en/integrations/openclaw) released**: install it from
[ClawHub](https://clawhub.ai/agentscope-ai/plugins/reme-openclaw-plugin) or
[npm](https://www.npmjs.com/package/@agentscope-ai/reme-openclaw-plugin) to add native memory recall, automatic

View file

@ -47,6 +47,8 @@
## 📰 最新动态
- [2026.09] - **[Hermes Agent 记忆 Provider](integrations/hermes_agent/README_ZH.md) 已可使用**:支持 HTTP 和 Embedded
两种模式,在模型调用前自动召回、每轮对话结束后异步执行 `auto_memory`。集成支持 Hermes Agent 0.21 及以上版本,后台任务也会继承当前 profile 上下文。
- [2026.09] - **[OpenClaw 插件](https://reme.agentscope.io/zh/integrations/openclaw) 发布**:可通过
[ClawHub](https://clawhub.ai/agentscope-ai/plugins/reme-openclaw-plugin) 或
[npm](https://www.npmjs.com/package/@agentscope-ai/reme-openclaw-plugin) 安装,为 OpenClaw 提供原生记忆召回、自动对话捕获和定时整理能力。

View file

@ -48,6 +48,9 @@ hermes plugins install agentscope-ai/ReMe/integrations/hermes_agent
hermes memory setup
```
Hermes releases whose plugin catalog includes ReMe also accept the shorter
`hermes plugins install reme` form.
For development from local ReMe and Hermes checkouts, either copy the integration
into the active profile or link it as a project-local plugin. The link keeps
Hermes on the exact ReMe source being edited:
@ -209,8 +212,11 @@ Run `hermes memory status` after installation, then start a new Hermes session.
## Verified end-to-end behavior
The screenshots below were captured with Computer Use from real English Hermes
0.21.1 and ReMe Studio 0.4.1.11 interfaces. The conversations used an
OpenAI-compatible model endpoint. Each mode used an isolated temporary Hermes
0.21.1 and ReMe Studio 0.4.1.11 interfaces. During development, focused
compatibility checks were run with Hermes 0.21.0 and the then-current `main`
using the real provider contract and plugin loader; the plugin validator was
also run against that `main`. These checks are not part of ReMe CI. The
conversations used an OpenAI-compatible model endpoint. Each mode used an isolated temporary Hermes
profile and ReMe workspace. The first session recorded a synthetic fact through
`auto_memory`; a fresh session then recovered it through automatic `prefetch`.
No API keys, `.env` contents, browser chrome, or personal memories appear in the

View file

@ -39,6 +39,9 @@ hermes plugins install agentscope-ai/ReMe/integrations/hermes_agent
hermes memory setup
```
在已经把 ReMe 收录进插件目录的 Hermes 版本中,也可以使用更短的
`hermes plugins install reme`。
本地同时开发 ReMe 和 Hermes 时,可以复制当前 checkout,也可以把它链接为 project-local plugin。软链接会让 Hermes 始终
运行当前正在编辑的 ReMe 源码:
@ -172,8 +175,10 @@ ReMe 搜索覆盖整个 workspace。多个 Hermes profile 指向同一个 worksp
## 真实端到端验证
以下截图使用 Computer Use 从真实英文 Hermes 0.21.1 与 ReMe Studio 0.4.1.11 界面取得,模型通过
OpenAI-compatible 接口调用。HTTP 与 Embedded 分别使用隔离的临时 Hermes profile 和 ReMe workspace:第一个会话通过
以下截图使用 Computer Use 从真实英文 Hermes 0.21.1 与 ReMe Studio 0.4.1.11 界面取得。开发期间还曾针对最低支持的
Hermes 0.21.0 与当时的 `main` 分支运行聚焦兼容性检查,使用真实 provider contract 和插件 loader,并针对该 `main`
运行插件 validator。这些检查不属于 ReMe CI。
模型通过 OpenAI-compatible 接口调用。HTTP 与 Embedded 分别使用隔离的临时 Hermes profile 和 ReMe workspace:第一个会话通过
`auto_memory` 写入合成事实,第二个全新会话通过自动 `prefetch` 召回。图片不包含 API Key、`.env` 内容、浏览器外框或
真实个人记忆。

View file

@ -3,7 +3,6 @@
from __future__ import annotations
import atexit
import contextvars
import hashlib
import importlib.util
import logging
@ -17,7 +16,7 @@ from typing import Any, Dict, List, Optional
from agent.memory_provider import MemoryProvider, RecallStatus
from .backend import ReMeBackend, ReMeBackendError
from .backend import ReMeBackend, ReMeBackendError, spawn_profile_thread
from .config import ReMeConfig, ReMeConfigError, load_config, save_config
from .embedded_backend import EmbeddedReMeBackend
from .http_backend import HttpReMeBackend
@ -116,12 +115,22 @@ class ReMeMemoryProvider(MemoryProvider):
try:
config = load_config(hermes_home)
except ReMeConfigError as exc:
with self._backend_lock:
self._close_backend_locked()
self._config = None
self._backend_label = "invalid configuration"
self._session_id = str(session_id or "")
self._profile_id = str(kwargs.get("agent_identity") or "default")
self._accept_writes = False
self._recall_status = None
self._unavailable_reason = str(exc)
logger.warning("ReMe provider configuration is invalid: %s", exc)
return
with self._backend_lock:
self._close_backend_locked()
self._config = config
self._unavailable_reason = ""
self._backend_label = config.endpoint if config.mode == "http" else f"embedded:{config.workspace_dir}"
self._recall_timeout = config.recall_timeout
self._health_timeout = config.health_timeout
@ -448,11 +457,9 @@ class ReMeMemoryProvider(MemoryProvider):
if not self._accept_writes:
return False
if self._write_thread is None or not self._write_thread.is_alive():
context = contextvars.copy_context()
self._write_thread = threading.Thread(
target=context.run,
args=(self._write_loop, self._write_queue),
daemon=True,
self._write_thread = spawn_profile_thread(
self._write_loop,
args=(self._write_queue,),
name="reme-memory-writer",
)
self._write_thread.start()

View file

@ -2,7 +2,10 @@
from __future__ import annotations
from typing import Any, Protocol
import contextvars
import threading
from typing import Any, Callable, Protocol
class ReMeBackendError(RuntimeError):
@ -36,6 +39,25 @@ class ReMeBackend(Protocol):
"""Release resources within a bounded interval."""
def spawn_profile_thread(
target: Callable[..., Any],
*,
name: str,
args: tuple[Any, ...] = (),
) -> threading.Thread:
"""Create a profile-aware daemon thread across supported Hermes versions."""
try:
from agent.memory_provider import spawn_context_thread
except ImportError:
context = contextvars.copy_context()
def run() -> None:
context.run(target, *args)
return threading.Thread(target=run, name=name, daemon=True)
return spawn_context_thread(target, name=name, args=args)
def require_healthy(response: dict[str, Any]) -> dict[str, Any]:
"""Validate the semantic health flag in a successful ReMe response."""
metadata = response.get("metadata")

View file

@ -10,7 +10,7 @@ import time
from enum import Enum
from typing import Any, Coroutine
from .backend import ReMeBackendError, require_healthy
from .backend import ReMeBackendError, require_healthy, spawn_profile_thread
class _State(Enum):
@ -66,9 +66,8 @@ class EmbeddedReMeBackend:
f"Embedded ReMe cannot start from state {self._state.value}{detail}",
)
self._state = _State.STARTING
self._thread = threading.Thread(
target=self._run_loop,
daemon=True,
self._thread = spawn_profile_thread(
self._run_loop,
name="reme-embedded-loop",
)
self._thread.start()

View file

@ -2,7 +2,6 @@ name: reme
version: 0.1.0
description: "ReMe local-first, file-native long-term memory for Hermes Agent (HTTP or embedded)."
kind: exclusive
manifest_version: 2
requires_hermes: ">=0.21"
author: agentscope-ai
homepage: https://github.com/agentscope-ai/ReMe

View file

@ -1,14 +1,17 @@
"""Focused tests for the external Hermes memory-provider plugin."""
# pylint: disable=missing-class-docstring,missing-function-docstring
# pylint: disable=protected-access,wrong-import-position,unused-import
# pylint: disable=protected-access,redefined-outer-name,wrong-import-position,unused-import
from __future__ import annotations
import sys
import types
import asyncio
import contextvars
import http.server
import importlib
import json
import threading
import time
@ -47,6 +50,7 @@ PLUGIN_MODULE = importlib.import_module("hermes_agent")
BACKEND_MODULE = importlib.import_module("hermes_agent.backend")
CONFIG_MODULE = importlib.import_module("hermes_agent.config")
EMBEDDED_MODULE = importlib.import_module("hermes_agent.embedded_backend")
HTTP_BACKEND_MODULE = importlib.import_module("hermes_agent.http_backend")
CLIENT_MODULE = importlib.import_module("hermes_agent.client")
ReMeMemoryProvider = PLUGIN_MODULE.ReMeMemoryProvider
@ -58,10 +62,71 @@ load_config = CONFIG_MODULE.load_config
parse_config = CONFIG_MODULE.parse_config
save_config = CONFIG_MODULE.save_config
EmbeddedReMeBackend = EMBEDDED_MODULE.EmbeddedReMeBackend
HttpReMeBackend = HTTP_BACKEND_MODULE.HttpReMeBackend
ReMeHttpClient = CLIENT_MODULE.ReMeHttpClient
ReMeServiceError = CLIENT_MODULE.ReMeServiceError
scoped_session_id = PLUGIN_MODULE._scoped_session_id
@pytest.fixture
def reme_http_server():
requests = []
class Handler(http.server.BaseHTTPRequestHandler):
def do_POST(self): # noqa: N802
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length)
requests.append((self.path, json.loads(body)))
action = self.path.rsplit("/", 1)[-1]
if action == "slow":
time.sleep(0.3)
if action == "server_error":
self.send_response(503)
self.end_headers()
self.wfile.write(b"temporarily unavailable")
return
if action == "invalid_json":
self.send_response(200)
self.end_headers()
self.wfile.write(b"not-json")
return
responses = {
"health_check": {"success": True, "metadata": {"health": {"healthy": True}}},
"unhealthy": {"success": True, "metadata": {"health": {"healthy": False}}},
"failed": {"success": False, "answer": "search failed"},
"list": [],
"search": {"success": True, "answer": "remembered", "metadata": {}},
"slow": {"success": True, "answer": "late", "metadata": {}},
}
payload = json.dumps(responses[action]).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
try:
self.wfile.write(payload)
except BrokenPipeError:
pass
def log_message(self, _format, *_args):
return
class Server(http.server.ThreadingHTTPServer):
daemon_threads = True
server = Server(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
yield f"http://127.0.0.1:{server.server_port}", requests
finally:
server.shutdown()
server.server_close()
thread.join(timeout=2)
def test_config_defaults_to_http(tmp_path):
config = load_config(tmp_path)
@ -152,6 +217,53 @@ def test_config_and_client_reject_unsafe_endpoint_shapes(tmp_path, endpoint):
ReMeHttpClient(endpoint, timeout=1)
def test_http_client_round_trip_sends_json_payload(reme_http_server):
endpoint, requests = reme_http_server
result = ReMeHttpClient(endpoint, timeout=1).call(
"search",
{"query": "project decision", "limit": 3},
)
assert result["answer"] == "remembered"
assert requests == [("/search", {"query": "project decision", "limit": 3})]
@pytest.mark.parametrize(
("action", "message"),
[
("server_error", "HTTP 503"),
("invalid_json", "invalid JSON"),
("failed", "search failed"),
("list", "non-object"),
],
)
def test_http_client_rejects_invalid_service_responses(reme_http_server, action, message):
endpoint, _ = reme_http_server
with pytest.raises(ReMeServiceError, match=message):
ReMeHttpClient(endpoint, timeout=1).call(action)
def test_http_client_enforces_request_timeout(reme_http_server):
endpoint, _ = reme_http_server
with pytest.raises(ReMeServiceError):
ReMeHttpClient(endpoint, timeout=1).call("slow", timeout=0.1)
def test_http_backend_requires_semantically_healthy_response(reme_http_server):
endpoint, _ = reme_http_server
backend = HttpReMeBackend(endpoint, request_timeout=1)
backend._client.call = lambda *args, **kwargs: {
"success": True,
"metadata": {"health": {"healthy": False}},
}
with pytest.raises(ReMeBackendError, match="healthy component snapshot"):
backend.health(timeout=1)
def test_save_config_uses_dashboard_layout_and_private_permissions(tmp_path):
saved = save_config({"mode": "http", "recall_limit": 7}, tmp_path)
path = config_path(tmp_path)
@ -306,6 +418,35 @@ def test_reinitialize_closes_previous_backend(monkeypatch, tmp_path):
provider.shutdown()
def test_invalid_reinitialize_closes_previous_backend_and_disables_writes(monkeypatch, tmp_path):
backend = _FakeBackend()
outcomes = iter([ReMeConfig(), ReMeConfigError("broken profile config")])
def load(_home=None):
outcome = next(outcomes)
if isinstance(outcome, Exception):
raise outcome
return outcome
monkeypatch.setattr(PLUGIN_MODULE, "load_config", load)
monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", lambda config: backend)
provider = ReMeMemoryProvider()
provider.initialize("first", hermes_home=tmp_path, agent_identity="first-profile")
provider.initialize("second", hermes_home=tmp_path, agent_identity="second-profile")
provider.sync_turn("user", "assistant")
assert backend.closed is True
assert provider._backend is None
assert provider._config is None
assert provider._session_id == "second"
assert provider._profile_id == "second-profile"
assert provider._accept_writes is False
assert provider.unavailable_reason() == "broken profile config"
assert provider.prefetch("query") == ""
assert provider._write_thread is None
def test_provider_failure_does_not_escape_model_path(monkeypatch, tmp_path):
backend = _FakeBackend()
backend.search = lambda *args, **kwargs: (_ for _ in ()).throw(
@ -430,6 +571,33 @@ def test_shutdown_drains_all_accepted_writes_before_closing_backend():
assert backend.closed is True
def test_writer_inherits_hermes_profile_context():
profile = contextvars.ContextVar("test_hermes_profile", default="default")
class ContextBackend(_FakeBackend):
def __init__(self):
super().__init__()
self.profile = None
def auto_memory(self, session_id, messages, *, timeout):
super().auto_memory(session_id, messages, timeout=timeout)
self.profile = profile.get()
backend = ContextBackend()
provider = ReMeMemoryProvider()
provider._config = ReMeConfig()
provider._backend = backend
provider._backend_available = True
token = profile.set("work")
try:
provider.sync_turn("user", "assistant", session_id="session")
finally:
profile.reset(token)
provider.shutdown()
assert backend.profile == "work"
def test_recall_timeout_includes_waiting_for_background_write():
write_entered = threading.Event()
write_release = threading.Event()