mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-30 01:52:29 +00:00
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
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:
parent
9ebe17a89d
commit
5231f3970c
9 changed files with 229 additions and 18 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 提供原生记忆召回、自动对话捕获和定时整理能力。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` 内容、浏览器外框或
|
||||
真实个人记忆。
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue