diff --git a/README.md b/README.md index 347ffc34..f53ab4c5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README_ZH.md b/README_ZH.md index 0b34f8f3..537e898f 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -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 提供原生记忆召回、自动对话捕获和定时整理能力。 diff --git a/integrations/hermes_agent/README.md b/integrations/hermes_agent/README.md index 0e95a1ea..42a64526 100644 --- a/integrations/hermes_agent/README.md +++ b/integrations/hermes_agent/README.md @@ -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 diff --git a/integrations/hermes_agent/README_ZH.md b/integrations/hermes_agent/README_ZH.md index 7a8c5303..7d695bc1 100644 --- a/integrations/hermes_agent/README_ZH.md +++ b/integrations/hermes_agent/README_ZH.md @@ -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` 内容、浏览器外框或 真实个人记忆。 diff --git a/integrations/hermes_agent/__init__.py b/integrations/hermes_agent/__init__.py index 2438923e..3a6e1409 100644 --- a/integrations/hermes_agent/__init__.py +++ b/integrations/hermes_agent/__init__.py @@ -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() diff --git a/integrations/hermes_agent/backend.py b/integrations/hermes_agent/backend.py index 9922dffc..11bd4546 100644 --- a/integrations/hermes_agent/backend.py +++ b/integrations/hermes_agent/backend.py @@ -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") diff --git a/integrations/hermes_agent/embedded_backend.py b/integrations/hermes_agent/embedded_backend.py index 5c805147..f517fa5e 100644 --- a/integrations/hermes_agent/embedded_backend.py +++ b/integrations/hermes_agent/embedded_backend.py @@ -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() diff --git a/integrations/hermes_agent/plugin.yaml b/integrations/hermes_agent/plugin.yaml index 58eb32fc..7994d48f 100644 --- a/integrations/hermes_agent/plugin.yaml +++ b/integrations/hermes_agent/plugin.yaml @@ -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 diff --git a/tests/unit/test_hermes_agent_integration.py b/tests/unit/test_hermes_agent_integration.py index 6c0d37a5..fb102b66 100644 --- a/tests/unit/test_hermes_agent_integration.py +++ b/tests/unit/test_hermes_agent_integration.py @@ -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()