From 1be61b1e4c01632618664f7affab1a6040724198 Mon Sep 17 00:00:00 2001
From: jinliyl <6469360+jinliyl@users.noreply.github.com>
Date: Fri, 11 Sep 2026 12:25:18 +0800
Subject: [PATCH] feat(integrations): add dual-mode Hermes memory provider
(#533)
* 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
---
AGENTS.md | 4 +-
README.md | 2 +-
README_ZH.md | 2 +-
docs/en/integrations.md | 2 +-
docs/en/reme-blog.md | 2 +-
docs/zh/integrations.md | 2 +-
docs/zh/integrations/hermes.md | 94 ++-
docs/zh/reme-blog.md | 2 +-
integrations/README.md | 11 -
integrations/hermes_agent/README.md | 332 ++++++++--
integrations/hermes_agent/README_ZH.md | 263 ++++++++
integrations/hermes_agent/__init__.py | 582 ++++++++++++------
integrations/hermes_agent/backend.py | 45 ++
integrations/hermes_agent/client.py | 27 +-
integrations/hermes_agent/config.py | 208 +++++++
integrations/hermes_agent/config_schema.py | 109 ++++
integrations/hermes_agent/embedded_backend.py | 323 ++++++++++
.../figures/hermes-embedded-recall.png | Bin 0 -> 545612 bytes
.../figures/hermes-http-recall.png | Bin 0 -> 360141 bytes
.../figures/hermes-http-sessions.png | Bin 0 -> 308738 bytes
.../figures/hermes-provider-settings.png | Bin 0 -> 365419 bytes
.../figures/hermes-reme-daily-note.png | Bin 0 -> 209446 bytes
integrations/hermes_agent/http_backend.py | 58 ++
integrations/hermes_agent/plugin.yaml | 11 +-
tests/unit/test_hermes_agent_integration.py | 569 +++++++++++++++++
25 files changed, 2349 insertions(+), 299 deletions(-)
delete mode 100644 integrations/README.md
create mode 100644 integrations/hermes_agent/README_ZH.md
create mode 100644 integrations/hermes_agent/backend.py
create mode 100644 integrations/hermes_agent/config.py
create mode 100644 integrations/hermes_agent/config_schema.py
create mode 100644 integrations/hermes_agent/embedded_backend.py
create mode 100644 integrations/hermes_agent/figures/hermes-embedded-recall.png
create mode 100644 integrations/hermes_agent/figures/hermes-http-recall.png
create mode 100644 integrations/hermes_agent/figures/hermes-http-sessions.png
create mode 100644 integrations/hermes_agent/figures/hermes-provider-settings.png
create mode 100644 integrations/hermes_agent/figures/hermes-reme-daily-note.png
create mode 100644 integrations/hermes_agent/http_backend.py
create mode 100644 tests/unit/test_hermes_agent_integration.py
diff --git a/AGENTS.md b/AGENTS.md
index ba8ae6ac..0192c7fb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -56,8 +56,8 @@ and concise documentation together.
`@agentscope-ai/reme_studio` npm static distribution.
- `typescript/`: the independently published `@agentscope-ai/reme` package, including the shared TypeScript client and
DeepSeek Harness and OpenClaw adapters.
-- `plugins/`: installable ReMe extensions, including Auto Fin and LME/BEAM benchmark Steps and application presets.
-- `integrations/`: adapters that connect ReMe to external agent hosts, such as Claude Code, DSH, and Hermes Agent.
+- `plugins/`: installable ReMe extensions, including Auto Fin and LME/BEAM plugins.
+- `integrations/`: adapters that connect ReMe to external agent hosts, including Claude Code and Hermes Agent.
- `skills/`: standalone skills; `reme_memory` calls ReMe, while other skills may use separate tools or direct-file
conventions.
- `benchmark/` and `cookbook/`: runnable evaluations and example workflows.
diff --git a/README.md b/README.md
index 65c6e773..116c2c16 100644
--- a/README.md
+++ b/README.md
@@ -185,7 +185,7 @@ lifecycle according to the capabilities of each runtime.
| **OpenClaw** | Install [`@agentscope-ai/reme`](typescript/README.md#openclaw) with `openclaw plugins install @agentscope-ai/reme`. | Native memory tools, recall before user-triggered runs, and automatic turn capture. |
| **QwenPaw** | Embed ReMe in-process through its Python API. | Reuse the host lifecycle and model config while keeping memory local and file-based. |
| **Claude Code** | Start the streamable HTTP MCP service and install [the ReMe plugin](integrations/claude_code/reme). | MCP recall tools, the `reme-memory` skill, and a Stop hook that records sessions automatically. |
-| **Hermes** | Start the HTTP service and install [the ReMe provider](integrations/hermes_agent). | Recall before model calls and asynchronous `auto_memory` after each completed turn. |
+| **Hermes** | Install [the ReMe provider](integrations/hermes_agent) and choose HTTP or embedded mode. | Recall before model calls and asynchronous `auto_memory` after each completed turn. |
| **Codex and other CLI agents** | Install or copy the [ReMe Memory skill](skills/reme_memory/SKILL.md). | Search, read, and write memory through the CLI; automatic capture requires host lifecycle integration. |
Integration demos
diff --git a/README_ZH.md b/README_ZH.md
index 11216b1e..c4b65120 100644
--- a/README_ZH.md
+++ b/README_ZH.md
@@ -183,7 +183,7 @@ runtime 的能力,将记忆指引、召回和捕获接入 Agent 生命周期
| **OpenClaw** | 使用 `openclaw plugins install @agentscope-ai/reme` 安装 [`@agentscope-ai/reme`](typescript/README_ZH.md#openclaw)。 | 原生记忆工具、用户触发运行前召回和自动对话捕获。 |
| **QwenPaw** | 通过 Python API 在进程内嵌入 ReMe。 | 复用宿主生命周期和模型配置,同时保持记忆本地、文件化。 |
| **Claude Code** | 启动 streamable HTTP MCP service,并安装 [ReMe 插件](integrations/claude_code/reme)。 | MCP 召回工具、`reme-memory` skill,以及自动记录会话的 Stop hook。 |
-| **Hermes** | 启动 HTTP service,并安装 [ReMe provider](integrations/hermes_agent)。 | 模型调用前召回,每轮对话完成后异步执行 `auto_memory`。 |
+| **Hermes** | 安装 [ReMe provider](integrations/hermes_agent),并选择 HTTP 或 Embedded 模式。 | 模型调用前召回,每轮对话完成后异步执行 `auto_memory`。 |
| **Codex 及其他 CLI Agent** | 安装或复制 [ReMe Memory skill](skills/reme_memory/SKILL.md)。 | 通过 CLI 搜索、读取和写入记忆;自动捕获需要显式接入宿主生命周期。 |
集成演示
diff --git a/docs/en/integrations.md b/docs/en/integrations.md
index 6e2e828a..fd1653a6 100644
--- a/docs/en/integrations.md
+++ b/docs/en/integrations.md
@@ -52,7 +52,7 @@ The [`@agentscope-ai/reme` TypeScript package](./integrations/typescript.md) pro
## Hermes Agent
-`integrations/hermes_agent/` provides a memory provider that recalls context before model calls and asynchronously invokes `auto_memory` after each turn.
+`integrations/hermes_agent/` provides a memory provider with HTTP and embedded modes. It recalls context before model calls and asynchronously invokes `auto_memory` after each turn. Its `config_schema.py` is rendered by Hermes' generic memory settings UI.
## Production guidance
diff --git a/docs/en/reme-blog.md b/docs/en/reme-blog.md
index 34732191..a0053233 100644
--- a/docs/en/reme-blog.md
+++ b/docs/en/reme-blog.md
@@ -320,7 +320,7 @@ that best fits their runtime environment and share the same local memory workspa
| **OpenClaw** | Install [`@agentscope-ai/reme`](../../typescript/README.md#openclaw) as the native memory plugin. | Recall before conversational root-agent runs, explicit search, automatic turn capture, and scheduled Auto Dream. |
| **QwenPaw** | Embed ReMe in-process through the Python API. | Reuse the host application's lifecycle and model configuration while keeping memories local and file-based. |
| **Claude Code** | Start the streamable HTTP MCP Service and install [`integrations/claude_code/reme`](../../integrations/claude_code/reme). | MCP memory-recall tools, the `reme-memory` skill, and a Stop hook that automatically records sessions. |
-| **Hermes** | Start the HTTP Service and install [`integrations/hermes_agent`](../../integrations/hermes_agent). | Automatically recall relevant memories before model calls and invoke `auto_memory` asynchronously after each conversation turn. |
+| **Hermes** | Install [`integrations/hermes_agent`](../../integrations/hermes_agent) and choose HTTP or embedded mode. | Automatically recall relevant memories before model calls and invoke `auto_memory` asynchronously after each conversation turn. |
| **Codex and other CLI-capable agents** | Copy or install [`skills/reme_memory/SKILL.md`](../../skills/reme_memory/SKILL.md). | Search, read, and write memories through the CLI; automatic recording requires the host agent to integrate explicitly with the conversation lifecycle. |
For installation, configuration, and integration demos, see the [README](../../README.md).
diff --git a/docs/zh/integrations.md b/docs/zh/integrations.md
index 671ce4af..dba50739 100644
--- a/docs/zh/integrations.md
+++ b/docs/zh/integrations.md
@@ -76,7 +76,7 @@ Skill 不应:
## Hermes Agent
-`integrations/hermes_agent/` 提供 memory provider:模型调用前检索相关记忆,每轮结束后异步调用 `auto_memory`。完整配置见该目录 README。
+`integrations/hermes_agent/` 提供 HTTP 和 Embedded 双模式 memory provider:模型调用前检索相关记忆,每轮结束后异步调用 `auto_memory`,并通过 Hermes 通用配置面板展示设置。完整配置见该目录 README。
## 生产接入建议
diff --git a/docs/zh/integrations/hermes.md b/docs/zh/integrations/hermes.md
index 2371b1f8..85416fec 100644
--- a/docs/zh/integrations/hermes.md
+++ b/docs/zh/integrations/hermes.md
@@ -1,15 +1,33 @@
---
title: Hermes Agent 集成
-description: 使用 ReMe memory provider 在 Hermes 调用模型前召回、每轮结束后异步记录。
+description: 使用 HTTP 或 Embedded ReMe memory provider,在模型调用前召回、每轮结束后异步记录。
---
# Hermes Agent 集成
-Hermes memory provider 连接到一个已经运行的 ReMe HTTP 服务,在每次模型调用前召回相关记忆,并在用户/助手回合完成后异步调用 `auto_memory`。
+ReMe 的 Hermes memory provider 支持两种运行方式:
-## Workspace 隔离
+- HTTP(默认):连接独立运行的 ReMe 服务,Hermes 环境不需要安装 ReMe SDK;
+- Embedded:在 Hermes Python 进程内创建 ReMe `Application`,不需要服务进程和端口。
-ReMe 的搜索范围是一个完整 workspace。多个 Hermes profile 指向同一个 workspace 时会共享召回结果;需要隔离时,为每个 profile 使用独立 workspace 和端点。
+两种模式都会在模型调用前执行 `search`,并在用户/助手回合完成后把 `auto_memory` 放入串行后台队列。
+
+## 安装与配置
+
+```bash
+hermes plugins install agentscope-ai/ReMe/integrations/hermes_agent
+hermes memory setup
+```
+
+Hermes Dashboard 会展示 provider 的模式相关字段和高级 recall、health、write、shutdown 设置;切换模式后只显示对应的
+HTTP endpoint 或 Embedded workspace 字段。
+
+配置主路径为 `$HERMES_HOME/reme/config.json`。新文件未包含的字段仍会从旧 `$HERMES_HOME/reme.json` 继承,新文件中的
+值优先;后续 CLI 保存会写入完整的新配置,但不会删除旧文件。
+
+## HTTP 模式
+
+为当前 Hermes profile 启动独立 workspace:
```bash
reme start \
@@ -19,41 +37,55 @@ reme start \
service.port=2333
```
-自动记忆需要 LLM;默认 BM25 搜索不需要 Embedding。
-
-## 安装与配置
-
-```bash
-hermes plugins install agentscope-ai/ReMe/integrations/hermes_agent
-hermes memory setup
-```
-
-选择 `reme`,接受默认的 `http://127.0.0.1:2333`,或输入上一步使用的端点。Setup 会先调用 `health_check`,只有新端点健康时才替换现有 provider 配置。
-
-配置存放在 `$HERMES_HOME/reme.json`:
+配置示例:
```json
{
- "endpoint": "http://127.0.0.1:2333",
- "request_timeout": 600.0,
- "recall_timeout": 5.0,
- "health_timeout": 2.0,
- "health_retry_seconds": 30.0,
- "shutdown_timeout": 30.0,
- "recall_limit": 5
+ "mode": "http",
+ "endpoint": "http://127.0.0.1:2333"
}
```
-运行 `hermes memory status` 检查安装和配置。新的 Hermes 会话还会重新检查端点健康状态。
+终端 setup 会在保存前调用 `health_check`。HTTP action 接口没有本集成专用的认证头,不要直接暴露到公网;跨主机使用时
+应放在可信网络、SSH tunnel 或带认证的反向代理后。
+
+## Embedded 模式
+
+先把 ReMe 安装到 Hermes 使用的同一个 Python 环境:
+
+```bash
+pip install "reme-ai[core]"
+```
+
+然后配置独立 workspace:
+
+```json
+{
+ "mode": "embedded",
+ "workspace_dir": "~/.reme-hermes-default",
+ "reme_config": "default"
+}
+```
+
+插件在专用 asyncio loop thread 上构造并启动 `reme.Application`,直接执行 `health_check`、`search` 和 `auto_memory`。
+关闭时会在有界时间内排空写队列、调用 `Application.close()`、停止 loop 并 join thread;不会调用
+`Application.run_app()`,因此不会监听端口。
+
+## Workspace 隔离
+
+ReMe 搜索覆盖整个 workspace。多个 Hermes profile 指向同一个 workspace 时会共享召回结果;除非明确需要共享,否则为
+每个 profile 配置不同 workspace。HTTP 模式通常也使用不同端口。
+
+自动记忆需要可用的 LLM 配置;默认 BM25 搜索不需要 Embedding,除非选用的 ReMe 配置启用了依赖 Embedding 的向量检索。
## 生命周期和失败行为
-- `prefetch` 调用 ReMe `search`,Hermes 将结果放入受保护的 memory context;
-- `sync_turn` 把完成的回合加入串行后台写队列,再调用 `auto_memory`;
+- `prefetch` 只返回 ReMe answer,受保护的 memory context 包装由 Hermes 统一添加;
+- 有召回内容时,Hermes UI 会显示 ReMe recall indicator 和可获得的结果数量;
+- `sync_turn` 只提交最新完整回合,并用 profile 与 session 共同生成安全 ID;
- cron、flush 和 subagent context 不写入对话记忆;
-- 健康检查失败后,在 cooldown 结束前暂停召回和记录;
-- 召回与写入有独立 cooldown,单项失败不会关闭另一项;
-- 召回使用较短超时,避免慢搜索长期阻塞模型调用;
-- shutdown 会在有限时间内排空写队列,ReMe 服务仍由用户独立管理。
+- backend 健康、召回和写入使用独立 cooldown;错误只记录警告,不中断 Hermes 对话;
+- 写队列目前位于内存,进程异常退出时尚未完成的写入可能丢失。
-英文权威安装说明位于 `integrations/hermes_agent/README.md`。
+运行 `hermes memory status` 检查插件状态,然后启动新的 Hermes 会话。完整字段、真实截图和故障排查见
+[`integrations/hermes_agent/README_ZH.md`](../../../integrations/hermes_agent/README_ZH.md)。
diff --git a/docs/zh/reme-blog.md b/docs/zh/reme-blog.md
index 1af0d05f..43c0b9a7 100644
--- a/docs/zh/reme-blog.md
+++ b/docs/zh/reme-blog.md
@@ -338,7 +338,7 @@ ReMe 既可以作为本地记忆服务,通过 CLI、HTTP API 或 MCP Server
| **OpenClaw** | 将 [`@agentscope-ai/reme`](../../typescript/README_ZH.md#openclaw) 安装为原生 memory plugin。 | 根 Agent 对话运行前召回、显式搜索、自动捕获对话,以及定时 Auto Dream。 |
| **QwenPaw** | 通过 Python API 在进程内嵌入 ReMe。 | 复用宿主应用的生命周期和模型配置,同时保持记忆本地、文件化。 |
| **Claude Code** | 启动 streamable HTTP MCP Service,并安装 [`integrations/claude_code/reme`](../../integrations/claude_code/reme)。 | MCP 记忆召回工具、`reme-memory` skill,以及自动记录会话的 Stop hook。 |
-| **Hermes** | 启动 HTTP Service,并安装 [`integrations/hermes_agent`](../../integrations/hermes_agent)。 | 在模型调用前自动召回相关记忆,并在每轮对话完成后异步调用 `auto_memory`。 |
+| **Hermes** | 安装 [`integrations/hermes_agent`](../../integrations/hermes_agent),并选择 HTTP 或 Embedded 模式。 | 在模型调用前自动召回相关记忆,并在每轮对话完成后异步调用 `auto_memory`。 |
| **Codex 等支持 CLI 的 Agent** | 复制或安装 [`skills/reme_memory/SKILL.md`](../../skills/reme_memory/SKILL.md)。 | 通过 CLI 搜索、读取和写入记忆;自动记录需要宿主 Agent 显式接入会话生命周期。 |
安装、配置与集成演示可查看 [README 中文版](../../README_ZH.md)。
diff --git a/integrations/README.md b/integrations/README.md
deleted file mode 100644
index d4ada720..00000000
--- a/integrations/README.md
+++ /dev/null
@@ -1,11 +0,0 @@
-# Agent Integrations
-
-This directory contains host-specific adapters that connect external agents to ReMe. An integration may use the host's
-plugin API, hooks, MCP configuration, or client interface, but it does not extend ReMe's runtime through the
-`reme.plugins` entry-point group.
-
-The shared TypeScript client and the DeepSeek Harness and OpenClaw adapters live in
-[`../typescript`](../typescript/README.md).
-
-Installable extensions of ReMe itself include [Auto Fin](../plugins/auto-fin/README.md) and
-[Daily Paper](../plugins/daily_paper/README.md).
diff --git a/integrations/hermes_agent/README.md b/integrations/hermes_agent/README.md
index 68fffccb..0e95a1ea 100644
--- a/integrations/hermes_agent/README.md
+++ b/integrations/hermes_agent/README.md
@@ -1,59 +1,167 @@
# ReMe memory provider for Hermes Agent
-This plugin connects Hermes Agent to a running ReMe HTTP service. It recalls
-relevant memory before each model call and records each completed turn through
-ReMe's automatic memory job.
+[中文说明](README_ZH.md)
-## Prerequisites
+This plugin gives Hermes Agent automatic ReMe recall and recording in either of
+two modes:
-- Python 3.11 or newer
-- A working Hermes Agent installation
-- ReMe installed with its core dependencies
-- One ReMe workspace and endpoint for each Hermes profile that should remain
- isolated
+- **HTTP** (default) connects to an independently managed ReMe service and does
+ not require the ReMe SDK in Hermes' Python environment.
+- **Embedded** creates a ReMe `Application` inside Hermes on a dedicated asyncio
+ loop thread. It needs `reme-ai` installed but no service process or port.
-ReMe search currently covers one whole workspace. Pointing multiple Hermes
-profiles at the same ReMe workspace therefore shares their recalled memory. Use
-a separate ReMe workspace and endpoint when profiles must be isolated.
+Both modes search before a model call and queue each completed user/assistant
+turn for automatic memory extraction. ReMe remains local-first: workspace files
+are the durable source of truth.
-## Start ReMe
+```text
+new Hermes turn
+ └─ ReMe prefetch → search → protected memory context → model call
-Start the HTTP service against a workspace dedicated to the active Hermes
-profile:
-
-```bash
-reme start \
- workspace_dir="$HOME/.reme-hermes-default" \
- service.backend=http \
- service.host=127.0.0.1 \
- service.port=2333
+completed user/assistant turn
+ └─ FIFO writer → auto_memory → workspace daily Markdown
```
-ReMe needs a working LLM configuration for automatic memory extraction. Its
-default search uses BM25, so embedding credentials are optional unless vector
-retrieval is enabled. Keep the service running while Hermes is active.
+Unlike the DSH adapter, this provider does not add a model-visible search tool.
+Hermes calls `prefetch()` automatically before every relevant model call and
+adds the returned evidence to its protected memory context.
+
+| Mode | ReMe process | Hermes dependency | Best for |
+| --- | --- | --- | --- |
+| HTTP | Separate `reme start` service | No ReMe SDK required | Process isolation, shared or independently managed services |
+| Embedded | Inside Hermes on a dedicated event-loop thread | `reme-ai[core]` | Simple local setup with no extra service or port |
+
+## Requirements
+
+- Python 3.11 or newer.
+- Hermes Agent 0.21 or newer.
+- A ReMe configuration with `health_check`, `search`, and `auto_memory` jobs.
+- A working ReMe model configuration for `auto_memory`; BM25 recall itself does
+ not require an embedding model.
## Install and configure
-Hermes supports installing a plugin from a repository subdirectory:
+Hermes can install the plugin directly from this repository subdirectory:
```bash
hermes plugins install agentscope-ai/ReMe/integrations/hermes_agent
hermes memory setup
```
-Select `reme`, accept `http://127.0.0.1:2333` or enter the endpoint used above.
-Setup calls ReMe `health_check` and only replaces an existing provider config
-after the endpoint reports healthy. Then start a new Hermes session.
-Configuration is stored in
-`$HERMES_HOME/reme.json`, so every Hermes profile can point to its own ReMe
-workspace.
+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:
-The file supports these optional settings:
+```bash
+mkdir -p "$HERMES_HOME/plugins/reme"
+cp -R /path/to/ReMe/integrations/hermes_agent/. "$HERMES_HOME/plugins/reme/"
+hermes plugins enable reme
+hermes config set memory.provider reme
+```
+
+```bash
+cd /path/to/hermes-agent
+mkdir -p .hermes/plugins
+ln -s /path/to/ReMe/integrations/hermes_agent .hermes/plugins/reme
+export HERMES_ENABLE_PROJECT_PLUGINS=1
+hermes config set memory.provider reme
+```
+
+`.hermes/` is Hermes runtime state and is ignored by the Hermes repository. Do
+not commit the link, profile configuration, conversations, or generated memory.
+
+Verify discovery before starting a conversation:
+
+```bash
+hermes plugins doctor /path/to/ReMe/integrations/hermes_agent --ci
+hermes memory status
+```
+
+The Hermes Dashboard renders the provider's mode-specific fields and advanced
+recall, health, write, and shutdown controls. Select **Plugins → Runtime provider
+plugins → Memory provider → reme**. The active mode controls whether the HTTP
+endpoint or embedded workspace settings are shown.
+
+
+
+Configuration is profile-local at:
+
+```text
+$HERMES_HOME/reme/config.json
+```
+
+The earlier `$HERMES_HOME/reme.json` location remains a fallback for fields
+omitted from the current file. Current values take precedence, and the next CLI
+setup save writes a complete current config without deleting the legacy file.
+
+## HTTP mode
+
+Install ReMe in the environment that will run its service, then start one
+service and workspace for the active Hermes profile:
+
+```bash
+reme start \
+ workspace_dir="$HOME/.reme-hermes-default" \
+ service.backend=http \
+ service.host=127.0.0.1 \
+ service.port=3456
+```
+
+Use this configuration in Hermes:
```json
{
+ "mode": "http",
+ "endpoint": "http://127.0.0.1:3456"
+}
+```
+
+Port `3456` is used in this guide so the verification service does not collide
+with another ReMe instance on the default `2333` port. It is not a new default.
+
+The setup wizard checks `health_check` before replacing a valid configuration.
+The generic desktop settings endpoint validates field types and choices; a new
+Hermes session validates the URL and performs the live health check. ReMe's action HTTP service has no
+integration-specific authentication, so keep it on loopback or place it behind
+a trusted tunnel or authenticated proxy.
+
+## Embedded mode
+
+Install ReMe into the same Python environment used by Hermes:
+
+```bash
+pip install "reme-ai[core]"
+```
+
+Then configure a dedicated workspace:
+
+```json
+{
+ "mode": "embedded",
+ "workspace_dir": "~/.reme-hermes-default",
+ "reme_config": "default"
+}
+```
+
+Embedded mode resolves the named ReMe config, overrides its `workspace_dir`,
+constructs and starts `reme.Application` on one long-lived event loop, and calls
+the `health_check`, `search`, and `auto_memory` jobs directly. Shutdown drains
+the Hermes write queue, closes the Application, stops the loop, and joins its
+thread within the configured timeout. It never calls `Application.run_app()` and
+therefore never opens a service port.
+
+ReMe needs a working model configuration for automatic memory extraction. The
+default search includes BM25, so embedding credentials are optional unless the
+selected ReMe config enables vector retrieval that requires them.
+
+## Full configuration
+
+```json
+{
+ "mode": "http",
"endpoint": "http://127.0.0.1:2333",
+ "workspace_dir": "",
+ "reme_config": "default",
"request_timeout": 600.0,
"recall_timeout": 5.0,
"health_timeout": 2.0,
@@ -63,22 +171,148 @@ The file supports these optional settings:
}
```
-Run `hermes memory status` to check that the provider is installed and
-configured. Starting a Hermes session performs a fresh endpoint health check.
+| Field | Default | Meaning |
+| --- | --- | --- |
+| `mode` | `http` | `http` or `embedded`. Missing values preserve legacy HTTP behavior. |
+| `endpoint` | `http://127.0.0.1:2333` | Absolute HTTP(S) service URL; credentials, query strings, and fragments are rejected. |
+| `workspace_dir` | empty | Required in embedded mode and normalized to an absolute path. |
+| `reme_config` | `default` | Built-in ReMe config name or YAML/JSON path for embedded mode. |
+| `recall_limit` | `5` | Maximum number of search results requested before a model call. |
+| `recall_timeout` | `5` | Maximum foreground recall time in seconds. |
+| `request_timeout` | `600` | Embedded startup and `auto_memory` write timeout in seconds. |
+| `health_timeout` | `2` | Health-probe timeout in seconds. |
+| `health_retry_seconds` | `30` | Cooldown before retrying an unavailable backend. |
+| `shutdown_timeout` | `30` | Total queue-drain and backend-close budget in seconds. |
+
+All numeric values must be finite and positive.
+
+ReMe search covers an entire workspace. Give each Hermes profile a different
+workspace unless cross-profile recall is intentional. HTTP profiles normally
+use different ports as well.
## Lifecycle and failure behavior
-- `prefetch` calls ReMe `search` and returns only its recalled text. Hermes wraps
- that text in its protected memory-context block.
-- `sync_turn` queues the completed user/assistant turn for a serial background
- writer, which calls ReMe `auto_memory` with a filename-safe ID derived from
- the Hermes profile and conversation.
-- Cron, flush, and subagent contexts do not write conversational memory.
-- A failed health check disables recall and recording until the retry cooldown
- expires. Retrieval and recording failures use independent cooldowns, so one
- action cannot disable the other while the ReMe service remains healthy.
-- Recall uses its own short timeout so a slow ReMe search cannot stall the
- Hermes model call for the longer automatic-memory timeout.
-- `shutdown` gives queued writes a bounded drain interval; ReMe remains an
- independently managed service. An idempotent process-exit hook uses the same
- drain path when a Hermes surface does not call provider shutdown directly.
+- `prefetch` returns only ReMe's answer; Hermes adds the protected
+ `` wrapper.
+- Successful recall exposes a Hermes recall indicator using ReMe's returned
+ result count when available.
+- `sync_turn` sends only the latest completed turn and uses a filename-safe ID
+ derived from both the Hermes profile and session.
+- Cron, flush, and subagent contexts do not record conversational memory.
+- Health, recall, and write cooldowns are independent. Backend failures log a
+ warning and do not fail the main Hermes conversation.
+- The writer is FIFO and in-memory. A process crash can lose queued turns;
+ persistent spooling is intentionally outside this first dual-mode version.
+
+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
+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
+images.
+
+### Reproduce the verification
+
+Run the focused compatibility suite against the Hermes checkout, then validate
+the plugin through Hermes' real loader:
+
+```bash
+cd /path/to/ReMe
+PYTHONPATH=/path/to/hermes-agent \
+ pytest tests/unit/test_hermes_agent_integration.py -v
+
+cd /path/to/hermes-agent
+hermes plugins doctor /path/to/ReMe/integrations/hermes_agent --ci
+hermes memory status
+```
+
+For a live model test, start ReMe on `3456`, select that endpoint in the ReMe
+provider settings, and use two new Hermes sessions: the first asks Hermes to
+remember a synthetic fact and the second asks for it back. When an existing
+ReMe `.env` uses `LLM_*` names, map them only in the process environment used
+for verification:
+
+```bash
+set -a
+source /path/to/ReMe/.env
+set +a
+export OPENAI_API_KEY="$LLM_API_KEY"
+export OPENAI_BASE_URL="$LLM_BASE_URL"
+
+hermes --provider openai-api -m "$LLM_MODEL_NAME" -z \
+ "Remember this synthetic fact for a later session: Project Juniper's weekly review is Thursday at 14:30 UTC."
+hermes --provider openai-api -m "$LLM_MODEL_NAME" -z \
+ "From long-term memory, when is Project Juniper's weekly review?"
+
+unset OPENAI_API_KEY OPENAI_BASE_URL
+```
+
+The second command must run without `--resume`. Confirm that a new Markdown
+note exists under the selected ReMe workspace's `daily/` directory; do not use
+the ReMe repository's `.reme/` directory for this check. Never print the loaded
+variables or save the mapped credentials in Hermes configuration.
+
+### Provider configuration
+
+
+
+### Two independent Hermes sessions
+
+
+
+### HTTP mode recall
+
+
+
+### File-native durable result
+
+
+
+### Embedded mode recall
+
+
+
+## Troubleshooting
+
+### `hermes memory status` shows built-in memory only
+
+Enable the plugin and select it explicitly, then start a new session:
+
+```bash
+hermes plugins enable reme
+hermes config set memory.provider reme
+hermes memory status
+```
+
+### HTTP mode is unavailable
+
+- Confirm `reme start` is still running and its port matches `endpoint`.
+- Call `POST http://127.0.0.1:3456/health_check` and inspect ReMe service logs.
+- In containers or on another host, remember that `127.0.0.1` refers to the
+ Hermes machine; use a trusted tunnel or authenticated proxy.
+
+### Embedded mode is unavailable
+
+- Install `reme-ai[core]` into the exact Python environment that launches Hermes.
+- Use an absolute, writable workspace outside the source repository.
+- Check that the selected ReMe config exposes all three required jobs.
+
+### Recall works but completed turns are not written
+
+- `cron`, `flush`, and `subagent` contexts intentionally skip writes.
+- Wait for the asynchronous writer before inspecting `daily/`.
+- Check Hermes and ReMe logs for an `auto_memory` error or cooldown warning.
+- The current FIFO queue is in memory; an abrupt process exit may lose pending writes.
+
+### Recall returns no useful context
+
+- Confirm the expected Markdown exists under the configured workspace's
+ `daily/` or `digest/` directory.
+- Use a focused prompt and increase `recall_limit` only when necessary.
+- Rebuild derived ReMe indexes from workspace files; never rewrite source memory
+ merely to repair an index.
diff --git a/integrations/hermes_agent/README_ZH.md b/integrations/hermes_agent/README_ZH.md
new file mode 100644
index 00000000..7a8c5303
--- /dev/null
+++ b/integrations/hermes_agent/README_ZH.md
@@ -0,0 +1,263 @@
+# Hermes Agent 集成
+
+[English](README.md)
+
+ReMe 的 Hermes memory provider 支持两种运行方式:
+
+- HTTP(默认):连接独立运行的 ReMe 服务,Hermes 环境不需要安装 ReMe SDK;
+- Embedded:在 Hermes Python 进程内创建 ReMe `Application`,不需要服务进程和端口。
+
+两种模式都会在模型调用前执行 `search`,并在用户/助手回合完成后把 `auto_memory` 放入串行后台队列。
+
+```text
+Hermes 新回合
+ └─ ReMe prefetch → search → 受保护 memory context → 模型调用
+
+完整 user/assistant 回合
+ └─ FIFO 写入队列 → auto_memory → workspace daily Markdown
+```
+
+与 DSH 适配器不同,这个 provider 不增加模型可见的搜索工具。Hermes 会在相关模型调用前自动执行 `prefetch()`,再把
+ReMe 返回的证据加入受保护的 memory context。
+
+| 模式 | ReMe 运行位置 | Hermes 环境依赖 | 适用场景 |
+| --- | --- | --- | --- |
+| HTTP | 独立 `reme start` 服务 | 不需要 ReMe SDK | 进程隔离、共享服务或独立运维 |
+| Embedded | Hermes 进程内的专用 event-loop thread | `reme-ai[core]` | 单机使用,不希望维护服务和端口 |
+
+## 环境要求
+
+- Python 3.11 或更高版本;
+- Hermes Agent 0.21 或更高版本;
+- ReMe 配置包含 `health_check`、`search`、`auto_memory` Job;
+- `auto_memory` 需要可用的 ReMe 模型配置;仅 BM25 召回不要求 Embedding 模型。
+
+## 安装与配置
+
+```bash
+hermes plugins install agentscope-ai/ReMe/integrations/hermes_agent
+hermes memory setup
+```
+
+本地同时开发 ReMe 和 Hermes 时,可以复制当前 checkout,也可以把它链接为 project-local plugin。软链接会让 Hermes 始终
+运行当前正在编辑的 ReMe 源码:
+
+```bash
+mkdir -p "$HERMES_HOME/plugins/reme"
+cp -R /path/to/ReMe/integrations/hermes_agent/. "$HERMES_HOME/plugins/reme/"
+hermes plugins enable reme
+hermes config set memory.provider reme
+```
+
+```bash
+cd /path/to/hermes-agent
+mkdir -p .hermes/plugins
+ln -s /path/to/ReMe/integrations/hermes_agent .hermes/plugins/reme
+export HERMES_ENABLE_PROJECT_PLUGINS=1
+hermes config set memory.provider reme
+```
+
+Hermes 仓库会忽略 `.hermes/` 运行状态。不要提交这个链接、profile 配置、对话记录或生成的记忆文件。
+
+启动对话前检查真实发现路径:
+
+```bash
+hermes plugins doctor /path/to/ReMe/integrations/hermes_agent --ci
+hermes memory status
+```
+
+Hermes Dashboard 的 **Plugins → Runtime provider plugins → Memory provider → reme** 会展示模式相关字段以及召回、健康、
+写入和关闭的高级设置。选择不同模式时,只显示 HTTP endpoint 或 Embedded workspace 对应字段。
+
+
+
+配置主路径为 `$HERMES_HOME/reme/config.json`。新文件未包含的字段仍会从旧 `$HERMES_HOME/reme.json` 继承,新文件中的
+值优先;后续 CLI 保存会写入完整的新配置,但不会删除旧文件。
+
+## HTTP 模式
+
+为当前 Hermes profile 启动独立 workspace:
+
+```bash
+reme start \
+ workspace_dir="/absolute/path/to/reme-hermes-default" \
+ service.backend=http \
+ service.host=127.0.0.1 \
+ service.port=3456
+```
+
+配置示例:
+
+```json
+{
+ "mode": "http",
+ "endpoint": "http://127.0.0.1:3456"
+}
+```
+
+本文使用 `3456` 做联调端口,以免与默认 `2333` 上的其他 ReMe 实例冲突;这不是修改 ReMe 默认端口。
+
+终端 setup 会在保存前调用 `health_check`。HTTP action 接口没有本集成专用的认证头,不要直接暴露到公网;跨主机使用时
+应放在可信网络、SSH tunnel 或带认证的反向代理后。
+
+## Embedded 模式
+
+先把 ReMe 安装到 Hermes 使用的同一个 Python 环境:
+
+```bash
+pip install "reme-ai[core]"
+```
+
+然后配置独立 workspace:
+
+```json
+{
+ "mode": "embedded",
+ "workspace_dir": "~/.reme-hermes-default",
+ "reme_config": "default"
+}
+```
+
+插件在专用 asyncio loop thread 上构造并启动 `reme.Application`,直接执行 `health_check`、`search` 和 `auto_memory`。
+关闭时会在有界时间内排空写队列、调用 `Application.close()`、停止 loop 并 join thread;不会调用
+`Application.run_app()`,因此不会监听端口。
+
+## 完整配置
+
+```json
+{
+ "mode": "http",
+ "endpoint": "http://127.0.0.1:2333",
+ "workspace_dir": "",
+ "reme_config": "default",
+ "request_timeout": 600.0,
+ "recall_timeout": 5.0,
+ "health_timeout": 2.0,
+ "health_retry_seconds": 30.0,
+ "shutdown_timeout": 30.0,
+ "recall_limit": 5
+}
+```
+
+| 字段 | 默认值 | 说明 |
+| --- | --- | --- |
+| `mode` | `http` | `http` 或 `embedded`;缺失时保持旧版 HTTP 行为。 |
+| `endpoint` | `http://127.0.0.1:2333` | HTTP(S) 绝对地址;拒绝 URL 凭据、query 和 fragment。 |
+| `workspace_dir` | 空 | Embedded 模式必填,并规范化为绝对路径。 |
+| `reme_config` | `default` | Embedded 使用的内置配置名或 YAML/JSON 路径。 |
+| `recall_limit` | `5` | 每次模型调用前请求的最大检索结果数。 |
+| `recall_timeout` | `5` | 前台召回最长等待秒数。 |
+| `request_timeout` | `600` | Embedded 启动和 `auto_memory` 写入超时。 |
+| `health_timeout` | `2` | 健康检查超时。 |
+| `health_retry_seconds` | `30` | backend 不可用后的重试冷却时间。 |
+| `shutdown_timeout` | `30` | 排空队列和关闭 backend 的总时间预算。 |
+
+所有数值都必须是有限正数。
+
+## Workspace 隔离
+
+ReMe 搜索覆盖整个 workspace。多个 Hermes profile 指向同一个 workspace 时会共享召回结果;除非明确需要共享,否则为
+每个 profile 配置不同 workspace。HTTP 模式通常也使用不同端口。
+
+自动记忆需要可用的 LLM 配置;默认 BM25 搜索不需要 Embedding,除非选用的 ReMe 配置启用了依赖 Embedding 的向量检索。
+
+## 生命周期和失败行为
+
+- `prefetch` 只返回 ReMe answer,受保护的 memory context 包装由 Hermes 统一添加;
+- 有召回内容时,Hermes UI 会显示 ReMe recall indicator 和可获得的结果数量;
+- `sync_turn` 只提交最新完整回合,并用 profile 与 session 共同生成安全 ID;
+- cron、flush 和 subagent context 不写入对话记忆;
+- backend 健康、召回和写入使用独立 cooldown;错误只记录警告,不中断 Hermes 对话;
+- 写队列目前位于内存,进程异常退出时尚未完成的写入可能丢失。
+
+## 真实端到端验证
+
+以下截图使用 Computer Use 从真实英文 Hermes 0.21.1 与 ReMe Studio 0.4.1.11 界面取得,模型通过
+OpenAI-compatible 接口调用。HTTP 与 Embedded 分别使用隔离的临时 Hermes profile 和 ReMe workspace:第一个会话通过
+`auto_memory` 写入合成事实,第二个全新会话通过自动 `prefetch` 召回。图片不包含 API Key、`.env` 内容、浏览器外框或
+真实个人记忆。
+
+### 复现验证
+
+先让聚焦单测使用真实 Hermes 源码,再通过 Hermes 自身的 loader 验证插件发现和注册:
+
+```bash
+cd /path/to/ReMe
+PYTHONPATH=/path/to/hermes-agent \
+ pytest tests/unit/test_hermes_agent_integration.py -v
+
+cd /path/to/hermes-agent
+hermes plugins doctor /path/to/ReMe/integrations/hermes_agent --ci
+hermes memory status
+```
+
+真实模型验证时,在 `3456` 启动 ReMe,把 provider endpoint 指向该服务,并使用两个全新的 Hermes 会话:第一个会话要求
+记住一条虚构事实,第二个会话要求重新回答。如果现有 ReMe `.env` 使用 `LLM_*` 变量,只在验证进程环境中映射它们:
+
+```bash
+set -a
+source /path/to/ReMe/.env
+set +a
+export OPENAI_API_KEY="$LLM_API_KEY"
+export OPENAI_BASE_URL="$LLM_BASE_URL"
+
+hermes --provider openai-api -m "$LLM_MODEL_NAME" -z \
+ "Remember this synthetic fact for a later session: Project Juniper's weekly review is Thursday at 14:30 UTC."
+hermes --provider openai-api -m "$LLM_MODEL_NAME" -z \
+ "From long-term memory, when is Project Juniper's weekly review?"
+
+unset OPENAI_API_KEY OPENAI_BASE_URL
+```
+
+第二条命令不要使用 `--resume`。随后确认所选 ReMe workspace 的 `daily/` 下出现新的 Markdown;不要把 ReMe 仓库自身的
+`.reme/` 用作测试 workspace。不要打印这些变量,也不要把映射后的凭据保存到 Hermes 配置。
+
+### Provider 配置
+
+
+
+### 两个相互独立的 Hermes 会话
+
+
+
+### HTTP 模式召回
+
+
+
+### 文件原生的持久化结果
+
+
+
+### Embedded 模式召回
+
+
+
+## 常见问题
+
+### `hermes memory status` 仍显示 built-in only
+
+```bash
+hermes plugins enable reme
+hermes config set memory.provider reme
+```
+
+配置变更只对新会话生效。
+
+### HTTP 模式显示 backend unavailable
+
+- 确认 `reme start` 仍在运行,端口与 `endpoint` 一致;
+- 直接执行 `curl -s http://127.0.0.1:3456/health_check -X POST -H 'Content-Type: application/json' -d '{}'`;
+- 容器或跨机器部署时,`127.0.0.1` 指向各自本机;
+- 不要把无认证的 ReMe HTTP action 服务直接暴露到公网。
+
+### Embedded 模式提示缺少 SDK
+
+必须把 `reme-ai[core]` 安装到 Hermes 实际使用的 Python 环境,而不是另一个虚拟环境。用
+`python -c 'import reme; print(reme.__version__)'` 核对。
+
+### 对话完成但 workspace 尚未出现新记录
+
+写入由后台 FIFO 队列执行。正常退出会在 `shutdown_timeout` 内排空,但进程崩溃仍可能丢失尚未写入的回合。检查 Hermes
+日志、ReMe 模型配置和 workspace 的 `daily/` 目录。
+
+运行 `hermes memory status` 检查插件状态,然后启动新的 Hermes 会话。
diff --git a/integrations/hermes_agent/__init__.py b/integrations/hermes_agent/__init__.py
index 8139cb12..2438923e 100644
--- a/integrations/hermes_agent/__init__.py
+++ b/integrations/hermes_agent/__init__.py
@@ -1,75 +1,30 @@
-"""Hermes Agent memory provider backed by a running ReMe HTTP service."""
+"""Hermes Agent memory provider backed by HTTP or an embedded ReMe SDK."""
from __future__ import annotations
import atexit
+import contextvars
import hashlib
-import json
+import importlib.util
import logging
-import os
import queue
import re
-import tempfile
import threading
import time
-from pathlib import Path
+from dataclasses import replace
from typing import Any, Dict, List, Optional
-from agent.memory_provider import MemoryProvider
+from agent.memory_provider import MemoryProvider, RecallStatus
-from .client import ReMeHttpClient, ReMeServiceError
+from .backend import ReMeBackend, ReMeBackendError
+from .config import ReMeConfig, ReMeConfigError, load_config, save_config
+from .embedded_backend import EmbeddedReMeBackend
+from .http_backend import HttpReMeBackend
logger = logging.getLogger(__name__)
-
-_CONFIG_FILENAME = "reme.json"
-_DEFAULT_CONFIG: dict[str, Any] = {
- "endpoint": "http://127.0.0.1:2333",
- "request_timeout": 600.0,
- "recall_timeout": 5.0,
- "health_timeout": 2.0,
- "health_retry_seconds": 30.0,
- "shutdown_timeout": 30.0,
- "recall_limit": 5,
-}
_NON_FILENAME_CHARS = re.compile(r"[^A-Za-z0-9._-]+")
-
-
-def _config_path(hermes_home: str | Path | None = None) -> Path:
- if hermes_home is None:
- from hermes_constants import get_hermes_home
-
- hermes_home = get_hermes_home()
- return Path(hermes_home).expanduser() / _CONFIG_FILENAME
-
-
-def _load_config(hermes_home: str | Path | None = None) -> dict[str, Any]:
- config = dict(_DEFAULT_CONFIG)
- path = _config_path(hermes_home)
- if not path.is_file():
- return config
- try:
- loaded = json.loads(path.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError) as exc:
- logger.warning("Unable to read ReMe provider config %s: %s", path, exc)
- return config
- if isinstance(loaded, dict):
- config.update({key: value for key, value in loaded.items() if value is not None and value != ""})
- return config
-
-
-def _positive_float(config: dict[str, Any], key: str) -> float:
- try:
- return max(0.1, float(config[key]))
- except (KeyError, TypeError, ValueError):
- return float(_DEFAULT_CONFIG[key])
-
-
-def _positive_int(config: dict[str, Any], key: str) -> int:
- try:
- return max(1, int(config[key]))
- except (KeyError, TypeError, ValueError):
- return int(_DEFAULT_CONFIG[key])
+_SDK_INSTALL_HINT = 'Embedded ReMe mode requires the SDK. Install it with: pip install "reme-ai[core]"'
def _slug(value: str, fallback: str, *, limit: int) -> str:
@@ -81,22 +36,35 @@ def _scoped_session_id(profile_id: str, session_id: str) -> str:
"""Create a readable, filename-safe ID without allowing scope collisions."""
profile = str(profile_id or "default")
session = str(session_id or "session")
- digest = hashlib.sha256(f"{profile}\0{session}".encode("utf-8")).hexdigest()[:12]
+ digest = hashlib.sha256(f"{profile}\0{session}".encode()).hexdigest()[:12]
return f"hermes-{_slug(profile, 'default', limit=32)}-{_slug(session, 'session', limit=64)}-{digest}"
+def _backend_for(config: ReMeConfig) -> ReMeBackend:
+ if config.mode == "embedded":
+ return EmbeddedReMeBackend(
+ config.workspace_dir,
+ reme_config=config.reme_config,
+ start_timeout=config.request_timeout,
+ )
+ return HttpReMeBackend(config.endpoint, request_timeout=config.request_timeout)
+
+
class ReMeMemoryProvider(MemoryProvider):
"""Use ReMe for automatic cross-session recall and recording in Hermes."""
def __init__(self) -> None:
- self._client: ReMeHttpClient | None = None
- self._endpoint = str(_DEFAULT_CONFIG["endpoint"])
- self._recall_timeout = float(_DEFAULT_CONFIG["recall_timeout"])
- self._health_timeout = float(_DEFAULT_CONFIG["health_timeout"])
- self._health_retry_seconds = float(_DEFAULT_CONFIG["health_retry_seconds"])
- self._shutdown_timeout = float(_DEFAULT_CONFIG["shutdown_timeout"])
- self._recall_limit = int(_DEFAULT_CONFIG["recall_limit"])
- self._service_available = False
+ defaults = ReMeConfig()
+ self._backend: ReMeBackend | None = None
+ self._config: ReMeConfig | None = None
+ self._backend_label = defaults.endpoint
+ self._recall_timeout = defaults.recall_timeout
+ self._health_timeout = defaults.health_timeout
+ self._health_retry_seconds = defaults.health_retry_seconds
+ self._shutdown_timeout = defaults.shutdown_timeout
+ self._request_timeout = defaults.request_timeout
+ self._recall_limit = defaults.recall_limit
+ self._backend_available = False
self._next_health_probe = 0.0
self._next_recall_attempt = 0.0
self._next_write_attempt = 0.0
@@ -107,8 +75,12 @@ class ReMeMemoryProvider(MemoryProvider):
self._write_queue: queue.Queue[dict[str, Any] | None] = queue.Queue()
self._write_thread: threading.Thread | None = None
self._write_thread_lock = threading.Lock()
+ self._backend_lock = threading.RLock()
self._shutdown_started = False
+ self._deferred_backend_close = False
self._atexit_registered = False
+ self._recall_status: RecallStatus | None = None
+ self._unavailable_reason = ""
@property
def name(self) -> str:
@@ -116,32 +88,56 @@ class ReMeMemoryProvider(MemoryProvider):
return "reme"
def is_available(self) -> bool:
- """Check local configuration only; network probes belong to initialize()."""
+ """Check configuration and local dependencies without network or writes."""
try:
- config = _load_config()
- ReMeHttpClient(str(config["endpoint"]), timeout=_positive_float(config, "request_timeout"))
- return True
- except (KeyError, TypeError, ValueError, OSError):
+ config = load_config()
+ if config.mode == "embedded" and importlib.util.find_spec("reme") is None:
+ self._unavailable_reason = _SDK_INSTALL_HINT
+ return False
+ _backend_for(config)
+ except (ReMeConfigError, TypeError, ValueError, OSError) as exc:
+ self._unavailable_reason = str(exc)
return False
+ self._unavailable_reason = ""
+ return True
+
+ def unavailable_reason(self) -> str:
+ """Return the last local availability failure as user-facing guidance."""
+ return self._unavailable_reason
def initialize(self, session_id: str, **kwargs: Any) -> None:
- """Load profile configuration and probe ReMe without blocking startup."""
+ """Load profile config and start the selected backend best-effort."""
with self._write_thread_lock:
if self._write_thread is not None and self._write_thread.is_alive():
- raise RuntimeError("Cannot reinitialize ReMe while its previous writer is still running")
+ raise RuntimeError(
+ "Cannot reinitialize ReMe while its previous writer is still running",
+ )
hermes_home = str(kwargs.get("hermes_home") or "") or None
- config = _load_config(hermes_home)
- self._endpoint = str(config["endpoint"])
- self._recall_timeout = _positive_float(config, "recall_timeout")
- self._health_timeout = _positive_float(config, "health_timeout")
- self._health_retry_seconds = _positive_float(config, "health_retry_seconds")
- self._shutdown_timeout = _positive_float(config, "shutdown_timeout")
- self._recall_limit = _positive_int(config, "recall_limit")
+ try:
+ config = load_config(hermes_home)
+ except ReMeConfigError as exc:
+ logger.warning("ReMe provider configuration is invalid: %s", exc)
+ return
+
+ with self._backend_lock:
+ self._close_backend_locked()
+ self._config = config
+ 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
+ self._health_retry_seconds = config.health_retry_seconds
+ self._shutdown_timeout = config.shutdown_timeout
+ self._request_timeout = config.request_timeout
+ self._recall_limit = config.recall_limit
self._session_id = str(session_id or "")
self._profile_id = str(kwargs.get("agent_identity") or "default")
- self._write_enabled = str(kwargs.get("agent_context") or "primary") not in {"cron", "flush", "subagent"}
- self._client = ReMeHttpClient(self._endpoint, timeout=_positive_float(config, "request_timeout"))
- self._service_available = False
+ self._write_enabled = str(kwargs.get("agent_context") or "primary") not in {
+ "cron",
+ "flush",
+ "subagent",
+ }
+ self._backend = None
+ self._backend_available = False
self._next_health_probe = 0.0
self._next_recall_attempt = 0.0
self._next_write_attempt = 0.0
@@ -149,77 +145,196 @@ class ReMeMemoryProvider(MemoryProvider):
self._write_queue = queue.Queue()
self._write_thread = None
self._shutdown_started = False
+ self._deferred_backend_close = False
+ self._recall_status = None
if not self._atexit_registered:
atexit.register(self._atexit_shutdown)
self._atexit_registered = True
-
- if not self._ensure_service(force=True):
+ if not self._ensure_backend(force=True):
logger.warning(
- "ReMe is unavailable at %s; recall is disabled and completed "
- "turns will not be recorded until it recovers",
- self._endpoint,
+ "ReMe is unavailable at %s; recall and recording will retry after cooldown",
+ self._backend_label,
)
def get_config_schema(self) -> List[Dict[str, Any]]:
- """Describe interactive setup fields understood by Hermes."""
+ """Describe fields used by the terminal setup wizard."""
+ defaults = ReMeConfig()
return [
{
- "key": "endpoint",
- "description": "ReMe HTTP service endpoint",
- "default": str(_DEFAULT_CONFIG["endpoint"]),
+ "key": "mode",
+ "label": "Mode",
+ "kind": "select",
+ "description": "How Hermes connects to ReMe",
+ "default": defaults.mode,
+ "choices": ["http", "embedded"],
"required": True,
},
+ {
+ "key": "endpoint",
+ "label": "HTTP endpoint",
+ "description": "ReMe service URL (HTTP mode only)",
+ "default": defaults.endpoint,
+ "required": True,
+ "when": {"mode": "http"},
+ },
+ {
+ "key": "workspace_dir",
+ "label": "Workspace directory",
+ "description": "ReMe workspace (embedded mode only)",
+ "default": "",
+ "required": True,
+ "when": {"mode": "embedded"},
+ },
+ {
+ "key": "reme_config",
+ "label": "ReMe configuration",
+ "description": "Built-in config name or YAML/JSON path (embedded mode only)",
+ "default": defaults.reme_config,
+ "required": True,
+ "when": {"mode": "embedded"},
+ },
+ {
+ "key": "recall_limit",
+ "label": "Recall limit",
+ "kind": "integer",
+ "description": "Maximum search results injected before a model call",
+ "default": defaults.recall_limit,
+ "minimum": 1,
+ },
+ {
+ "key": "recall_timeout",
+ "label": "Recall timeout (seconds)",
+ "kind": "number",
+ "default": defaults.recall_timeout,
+ "minimum": 0.1,
+ },
+ {
+ "key": "request_timeout",
+ "label": "Write/start timeout (seconds)",
+ "kind": "number",
+ "default": defaults.request_timeout,
+ "minimum": 0.1,
+ },
+ {
+ "key": "health_timeout",
+ "label": "Health timeout (seconds)",
+ "kind": "number",
+ "default": defaults.health_timeout,
+ "minimum": 0.1,
+ },
+ {
+ "key": "health_retry_seconds",
+ "label": "Health retry delay (seconds)",
+ "kind": "number",
+ "default": defaults.health_retry_seconds,
+ "minimum": 0.1,
+ },
+ {
+ "key": "shutdown_timeout",
+ "label": "Shutdown timeout (seconds)",
+ "kind": "number",
+ "default": defaults.shutdown_timeout,
+ "minimum": 0.1,
+ },
]
def save_config(self, values: Dict[str, Any], hermes_home: str) -> None:
- """Atomically save non-secret settings inside the active Hermes profile."""
- path = _config_path(hermes_home)
- existing = _load_config(hermes_home)
- existing.update({key: value for key, value in dict(values or {}).items() if value is not None and value != ""})
-
- # Validate before replacing a working configuration.
- client = ReMeHttpClient(
- str(existing["endpoint"]),
- timeout=_positive_float(existing, "request_timeout"),
- )
- client.health(timeout=_positive_float(existing, "health_timeout"))
- path.parent.mkdir(parents=True, exist_ok=True)
- fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
- try:
- with os.fdopen(fd, "w", encoding="utf-8") as handle:
- json.dump(existing, 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, path)
- finally:
- if os.path.exists(tmp_name):
- os.unlink(tmp_name)
+ """Validate and save terminal-wizard settings."""
+ candidate = dict(values or {})
+ current = load_config(hermes_home)
+ mode = str(candidate.get("mode", current.mode) or current.mode).strip().lower()
+ if mode == "http":
+ endpoint = str(
+ candidate.get("endpoint", current.endpoint) or current.endpoint,
+ )
+ probe = HttpReMeBackend(endpoint, request_timeout=current.request_timeout)
+ probe.health(timeout=current.health_timeout)
+ elif importlib.util.find_spec("reme") is None:
+ raise ReMeConfigError(_SDK_INSTALL_HINT)
+ save_config(candidate, hermes_home)
def get_tool_schemas(self) -> List[Dict[str, Any]]:
- """Automatic recall and capture add no model-visible tool schemas."""
+ """Automatic recall and capture add no model-visible tools."""
return []
- def prefetch(self, query: str, *, session_id: str = "") -> str:
- """Recall relevant memory before Hermes sends a turn to the model."""
- del session_id
- query = str(query or "").strip()
- if not query or time.monotonic() < self._next_recall_attempt or not self._ensure_service():
- return ""
- assert self._client is not None
+ def backup_paths(self) -> List[str]:
+ """Expose an embedded workspace to Hermes backup without starting ReMe."""
try:
- response = self._client.call(
- "search",
- {"query": query, "limit": self._recall_limit},
- timeout=self._recall_timeout,
- )
- except ReMeServiceError as exc:
- self._next_recall_attempt = time.monotonic() + self._health_retry_seconds
- logger.warning("ReMe retrieval failed at %s: %s", self._endpoint, exc)
+ config = load_config()
+ except ReMeConfigError:
+ return []
+ return [config.workspace_dir] if config.mode == "embedded" and config.workspace_dir else []
+
+ def recall_status(self) -> Optional[RecallStatus]:
+ """Describe only the content injected by the latest prefetch call."""
+ return self._recall_status
+
+ def prefetch(self, query: str, *, session_id: str = "") -> str:
+ """Recall relevant memory before Hermes sends the turn to the model."""
+ del session_id
+ self._recall_status = None
+ query = str(query or "").strip()
+ if not query or time.monotonic() < self._next_recall_attempt:
return ""
+ deadline = time.monotonic() + self._recall_timeout
+ if not self._backend_lock.acquire( # pylint: disable=consider-using-with
+ timeout=max(0.0, deadline - time.monotonic()),
+ ):
+ logger.warning(
+ "ReMe retrieval at %s timed out waiting for the backend",
+ self._backend_label,
+ )
+ return ""
+ try:
+ if not self._ensure_backend(deadline=deadline):
+ return ""
+ assert self._backend is not None
+ try:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ logger.warning(
+ "ReMe retrieval at %s exhausted its timeout before search",
+ self._backend_label,
+ )
+ return ""
+ response = self._backend.search(
+ query,
+ limit=self._recall_limit,
+ timeout=remaining,
+ )
+ except ReMeBackendError as exc:
+ self._next_recall_attempt = time.monotonic() + self._health_retry_seconds
+ logger.warning(
+ "ReMe retrieval failed at %s: %s",
+ self._backend_label,
+ exc,
+ )
+ return ""
+ finally:
+ self._close_backend_if_shutdown_locked()
+ finally:
+ self._backend_lock.release()
answer = response.get("answer")
- return answer.strip() if isinstance(answer, str) else ""
+ answer = answer.strip() if isinstance(answer, str) else ""
+ if answer:
+ self._recall_status = RecallStatus(
+ provider_label="ReMe",
+ count=self._result_count(response),
+ )
+ return answer
+
+ @staticmethod
+ def _result_count(response: dict[str, Any]) -> int:
+ metadata = response.get("metadata")
+ if not isinstance(metadata, dict):
+ return 0
+ counts = metadata.get("counts")
+ if isinstance(counts, dict):
+ returned = counts.get("returned")
+ if isinstance(returned, int) and not isinstance(returned, bool) and returned >= 0:
+ return returned
+ results = metadata.get("results")
+ return len(results) if isinstance(results, list) else 0
def sync_turn(
self,
@@ -229,7 +344,7 @@ class ReMeMemoryProvider(MemoryProvider):
session_id: str = "",
messages: Optional[List[Dict[str, Any]]] = None,
) -> None:
- """Queue one completed turn without blocking Hermes on ReMe's LLM."""
+ """Queue one completed turn without blocking Hermes on memory extraction."""
del messages
user = str(user_content or "").strip()
assistant = str(assistant_content or "").strip()
@@ -237,15 +352,10 @@ class ReMeMemoryProvider(MemoryProvider):
return
routed_session = str(session_id or self._session_id)
if not routed_session:
- logger.warning("ReMe skipped a completed turn because Hermes supplied no session id")
- return
- if not self._accept_writes:
logger.warning(
- "ReMe did not record completed turn for session %s because the provider is shutting down",
- _scoped_session_id(self._profile_id, routed_session),
+ "ReMe skipped a completed turn because Hermes supplied no session id",
)
return
-
payload = {
"session_id": _scoped_session_id(self._profile_id, routed_session),
"messages": [
@@ -255,7 +365,7 @@ class ReMeMemoryProvider(MemoryProvider):
}
if not self._enqueue_write(payload):
logger.warning(
- "ReMe did not record completed turn for session %s because the provider is shutting down",
+ "ReMe did not record session %s because the provider is shutting down",
payload["session_id"],
)
@@ -268,13 +378,14 @@ class ReMeMemoryProvider(MemoryProvider):
rewound: bool = False,
**kwargs: Any,
) -> None:
- """Update the active conversation boundary after a Hermes switch."""
+ """Route future writes to the newly active Hermes conversation."""
del parent_session_id, reset, rewound, kwargs
if new_session_id:
self._session_id = str(new_session_id)
def shutdown(self) -> None:
- """Drain queued writes for a bounded interval, then release state."""
+ """Drain queued writes, then close the backend within a bounded interval."""
+ deadline = time.monotonic() + self._shutdown_timeout
with self._write_thread_lock:
if self._shutdown_started:
return
@@ -284,26 +395,37 @@ class ReMeMemoryProvider(MemoryProvider):
if thread is not None:
self._write_queue.put(None)
if thread is not None:
- thread.join(timeout=self._shutdown_timeout)
+ thread.join(timeout=max(0.0, deadline - time.monotonic()))
if thread.is_alive():
abandoned = self._discard_queued_writes()
logger.warning(
- "ReMe shutdown timed out after %.1fs; abandoned %d queued write(s) "
- "and the in-flight write may not finish before process exit",
+ "ReMe shutdown timed out after %.1fs; abandoned %d queued write(s) and an in-flight write",
self._shutdown_timeout,
abandoned,
)
- else:
- self._client = None
+ self._deferred_backend_close = True
+ # A context manager cannot express the bounded wait required by shutdown.
+ if self._backend_lock.acquire( # pylint: disable=consider-using-with
+ timeout=max(0.0, deadline - time.monotonic()),
+ ):
+ try:
+ self._close_backend_locked(
+ timeout=max(0.0, deadline - time.monotonic()),
+ )
+ self._deferred_backend_close = False
+ finally:
+ self._backend_lock.release()
else:
- self._client = None
- self._service_available = False
+ logger.warning(
+ "ReMe backend shutdown is deferred until the in-flight operation finishes",
+ )
+ self._backend_available = False
self._next_health_probe = 0.0
def _atexit_shutdown(self) -> None:
try:
self.shutdown()
- except Exception as exc: # pragma: no cover - interpreter teardown safety
+ except Exception as exc: # pragma: no cover
logger.debug("ReMe atexit shutdown failed: %s", exc)
def _discard_queued_writes(self) -> int:
@@ -326,9 +448,10 @@ 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=self._write_loop,
- args=(self._write_queue,),
+ target=context.run,
+ args=(self._write_loop, self._write_queue),
daemon=True,
name="reme-memory-writer",
)
@@ -345,67 +468,142 @@ class ReMeMemoryProvider(MemoryProvider):
return
try:
self._record_payload(payload)
- except Exception as exc: # keep one bad response from killing the writer
+ except Exception as exc:
logger.exception(
- "Unexpected ReMe recording failure for session %s; the writer will continue: %s",
- payload.get("session_id", ""),
+ "Unexpected ReMe recording failure; writer continues: %s",
exc,
)
finally:
write_queue.task_done()
finally:
- current_thread = threading.current_thread()
+ current = threading.current_thread()
with self._write_thread_lock:
- if self._write_thread is current_thread:
+ if self._write_thread is current:
self._write_thread = None
- if not self._accept_writes:
- self._client = None
def _record_payload(self, payload: dict[str, Any]) -> None:
if time.monotonic() < self._next_write_attempt:
logger.warning(
- "ReMe did not record completed turn for session %s because writes are cooling down",
+ "ReMe write for session %s skipped during cooldown",
payload["session_id"],
)
return
- if not self._ensure_service():
- logger.warning(
- "ReMe did not record completed turn for session %s because the service is unavailable",
- payload["session_id"],
- )
- return
- assert self._client is not None
- try:
- self._client.call("auto_memory", payload)
- except ReMeServiceError as exc:
- self._next_write_attempt = time.monotonic() + self._health_retry_seconds
- logger.warning("ReMe recording failed at %s: %s", self._endpoint, exc)
- logger.warning(
- "ReMe did not record completed turn for session %s",
- payload["session_id"],
- )
+ with self._backend_lock:
+ if not self._ensure_backend(allow_shutdown=True):
+ logger.warning(
+ "ReMe write for session %s skipped because backend is unavailable",
+ payload["session_id"],
+ )
+ return
+ assert self._backend is not None
+ try:
+ self._backend.auto_memory(
+ payload["session_id"],
+ payload["messages"],
+ timeout=self._request_timeout,
+ )
+ except ReMeBackendError as exc:
+ self._next_write_attempt = time.monotonic() + self._health_retry_seconds
+ logger.warning(
+ "ReMe recording failed at %s: %s",
+ self._backend_label,
+ exc,
+ )
+ finally:
+ self._close_backend_if_shutdown_locked()
- def _ensure_service(self, *, force: bool = False) -> bool:
- if self._client is None:
- return False
- if self._service_available and not force:
+ # pylint: disable-next=too-many-return-statements
+ def _ensure_backend(
+ self,
+ *,
+ force: bool = False,
+ allow_shutdown: bool = False,
+ deadline: float | None = None,
+ ) -> bool:
+ with self._backend_lock:
+ if self._backend_available and self._backend is not None and not force:
+ return True
+ if self._config is None or (self._shutdown_started and not allow_shutdown):
+ return False
+ now = time.monotonic()
+ if not force and now < self._next_health_probe:
+ return False
+ if self._backend is None:
+ try:
+ backend_config = self._config
+ if deadline is not None:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ return False
+ backend_config = replace(
+ backend_config,
+ request_timeout=min(backend_config.request_timeout, remaining),
+ )
+ self._backend = _backend_for(backend_config)
+ if deadline is None:
+ self._backend.start()
+ else:
+ self._backend.start(deadline=deadline)
+ except (ReMeBackendError, TypeError, ValueError, OSError) as exc:
+ failed, self._backend = self._backend, None
+ if failed is not None:
+ try:
+ cleanup_timeout = self._shutdown_timeout
+ if deadline is not None:
+ cleanup_timeout = max(0.0, deadline - time.monotonic())
+ if deadline is None or cleanup_timeout > 0:
+ failed.close(timeout=cleanup_timeout)
+ except ReMeBackendError as close_exc:
+ logger.warning(
+ "Failed to clean up ReMe after startup error: %s",
+ close_exc,
+ )
+ self._mark_unavailable("startup", exc)
+ return False
+ try:
+ health_timeout = self._health_timeout
+ if deadline is not None:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ return False
+ health_timeout = min(health_timeout, remaining)
+ self._backend.health(timeout=health_timeout)
+ except ReMeBackendError as exc:
+ self._mark_unavailable("health check", exc)
+ return False
+ self._backend_available = True
+ self._next_health_probe = 0.0
return True
- now = time.monotonic()
- if not force and now < self._next_health_probe:
- return False
- try:
- self._client.health(timeout=self._health_timeout)
- except ReMeServiceError as exc:
- self._mark_unavailable("health check", exc)
- return False
- self._service_available = True
- self._next_health_probe = 0.0
- return True
+
+ def _close_backend_if_shutdown_locked(self) -> None:
+ if self._deferred_backend_close:
+ self._close_backend_locked()
+ self._deferred_backend_close = False
+
+ def _close_backend_locked(self, *, timeout: float | None = None) -> None:
+ backend, self._backend = self._backend, None
+ if backend is not None:
+ try:
+ backend.close(
+ timeout=self._shutdown_timeout if timeout is None else timeout,
+ )
+ except ReMeBackendError as exc:
+ logger.warning(
+ "ReMe backend shutdown failed at %s: %s",
+ self._backend_label,
+ exc,
+ )
+ self._backend_available = False
def _mark_unavailable(self, operation: str, error: Exception) -> None:
- self._service_available = False
+ self._backend_available = False
self._next_health_probe = time.monotonic() + self._health_retry_seconds
- logger.warning("ReMe %s failed at %s: %s", operation, self._endpoint, error)
+ logger.warning(
+ "ReMe %s failed at %s: %s",
+ operation,
+ self._backend_label,
+ error,
+ )
def register(ctx: Any) -> None:
diff --git a/integrations/hermes_agent/backend.py b/integrations/hermes_agent/backend.py
new file mode 100644
index 00000000..9922dffc
--- /dev/null
+++ b/integrations/hermes_agent/backend.py
@@ -0,0 +1,45 @@
+"""Transport-independent backend contract for the Hermes provider."""
+
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+
+class ReMeBackendError(RuntimeError):
+ """Raised when a ReMe backend cannot complete an operation."""
+
+
+class ReMeBackend(Protocol):
+ """Synchronous interface matching Hermes' memory-provider lifecycle."""
+
+ label: str
+
+ def start(self, *, deadline: float | None = None) -> None:
+ """Start owned resources before an optional absolute deadline."""
+
+ def health(self, *, timeout: float) -> dict[str, Any]:
+ """Return a semantically healthy ReMe response."""
+
+ def search(self, query: str, *, limit: int, timeout: float) -> dict[str, Any]:
+ """Search the configured ReMe workspace."""
+
+ def auto_memory(
+ self,
+ session_id: str,
+ messages: list[dict[str, Any]],
+ *,
+ timeout: float,
+ ) -> dict[str, Any]:
+ """Record and extract memory from one completed turn."""
+
+ def close(self, *, timeout: float) -> None:
+ """Release resources within a bounded interval."""
+
+
+def require_healthy(response: dict[str, Any]) -> dict[str, Any]:
+ """Validate the semantic health flag in a successful ReMe response."""
+ metadata = response.get("metadata")
+ health = metadata.get("health") if isinstance(metadata, dict) else None
+ if not isinstance(health, dict) or health.get("healthy") is not True:
+ raise ReMeBackendError("ReMe did not report a healthy component snapshot")
+ return response
diff --git a/integrations/hermes_agent/client.py b/integrations/hermes_agent/client.py
index 3521a217..cef5d9d8 100644
--- a/integrations/hermes_agent/client.py
+++ b/integrations/hermes_agent/client.py
@@ -15,15 +15,28 @@ class ReMeServiceError(RuntimeError):
"""Raised when a ReMe action cannot be completed successfully."""
+def normalize_http_endpoint(endpoint: str) -> str:
+ """Validate an HTTP service base URL without accepting embedded secrets."""
+ normalized = str(endpoint or "").strip().rstrip("/")
+ try:
+ parsed = urlsplit(normalized)
+ _ = parsed.port
+ except ValueError as exc:
+ raise ValueError("ReMe endpoint must be an absolute http(s) URL") from exc
+ if parsed.scheme not in {"http", "https"} or not parsed.hostname:
+ raise ValueError("ReMe endpoint must be an absolute http(s) URL")
+ if parsed.username is not None or parsed.password is not None:
+ raise ValueError("ReMe endpoint must be an absolute http(s) URL")
+ if parsed.query or parsed.fragment:
+ raise ValueError("ReMe endpoint must be an absolute http(s) URL")
+ return normalized
+
+
class ReMeHttpClient:
"""Call ReMe JSON actions without adding a runtime dependency to Hermes."""
def __init__(self, endpoint: str, *, timeout: float) -> None:
- endpoint = str(endpoint or "").strip().rstrip("/")
- parsed = urlsplit(endpoint)
- if parsed.scheme not in {"http", "https"} or not parsed.netloc:
- raise ValueError("ReMe endpoint must be an absolute http(s) URL")
- self.endpoint = endpoint
+ self.endpoint = normalize_http_endpoint(endpoint)
self.timeout = max(0.1, float(timeout))
def call(
@@ -63,7 +76,9 @@ class ReMeHttpClient:
if not isinstance(result, dict):
raise ReMeServiceError("ReMe returned a non-object response")
if result.get("success") is not True:
- raise ReMeServiceError(str(result.get("answer") or "ReMe action did not report success"))
+ raise ReMeServiceError(
+ str(result.get("answer") or "ReMe action did not report success"),
+ )
return result
def health(self, *, timeout: float) -> dict[str, Any]:
diff --git a/integrations/hermes_agent/config.py b/integrations/hermes_agent/config.py
new file mode 100644
index 00000000..78a5ff02
--- /dev/null
+++ b/integrations/hermes_agent/config.py
@@ -0,0 +1,208 @@
+"""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
diff --git a/integrations/hermes_agent/config_schema.py b/integrations/hermes_agent/config_schema.py
new file mode 100644
index 00000000..a73e4746
--- /dev/null
+++ b/integrations/hermes_agent/config_schema.py
@@ -0,0 +1,109 @@
+"""ReMe's configuration surface for Hermes' generic memory settings UI."""
+
+# pylint: disable=no-name-in-module
+
+from plugins.memory.config_schema import (
+ KIND_NUMBER,
+ KIND_SELECT,
+ KIND_TEXT,
+ ProviderConfigSchema,
+ ProviderField,
+ ProviderFieldOption,
+)
+
+CONFIG_SCHEMA = ProviderConfigSchema(
+ name="reme",
+ label="ReMe",
+ docs_url="https://github.com/agentscope-ai/ReMe/tree/main/integrations/hermes_agent",
+ fields=(
+ ProviderField(
+ key="mode",
+ label="Mode",
+ kind=KIND_SELECT,
+ default="http",
+ description="Choose a ReMe service or an in-process ReMe SDK.",
+ options=(
+ ProviderFieldOption(
+ "http",
+ "HTTP",
+ "Connect to an independently managed ReMe service",
+ ),
+ ProviderFieldOption(
+ "embedded",
+ "Embedded",
+ "Run ReMe inside the Hermes Python process",
+ ),
+ ),
+ inline=True,
+ ),
+ ProviderField(
+ key="endpoint",
+ label="HTTP endpoint",
+ kind=KIND_TEXT,
+ default="http://127.0.0.1:2333",
+ description="Used only in HTTP mode.",
+ placeholder="http://127.0.0.1:2333",
+ inline=True,
+ ),
+ ProviderField(
+ key="workspace_dir",
+ label="Workspace directory",
+ kind=KIND_TEXT,
+ default="",
+ description="Required only in embedded mode. Use a separate workspace for each Hermes profile.",
+ placeholder="~/.reme-hermes-default",
+ inline=True,
+ ),
+ ProviderField(
+ key="reme_config",
+ label="ReMe configuration",
+ kind=KIND_TEXT,
+ default="default",
+ description="Built-in config name or a YAML/JSON path used by embedded mode.",
+ group="Embedded",
+ ),
+ ProviderField(
+ key="recall_limit",
+ label="Recall limit",
+ kind=KIND_NUMBER,
+ default="5",
+ description="Maximum number of search results included before a model call.",
+ group="Recall",
+ ),
+ ProviderField(
+ key="recall_timeout",
+ label="Recall timeout (seconds)",
+ kind=KIND_NUMBER,
+ default="5",
+ group="Timeouts",
+ ),
+ ProviderField(
+ key="request_timeout",
+ label="Write/start timeout (seconds)",
+ kind=KIND_NUMBER,
+ default="600",
+ group="Timeouts",
+ ),
+ ProviderField(
+ key="health_timeout",
+ label="Health timeout (seconds)",
+ kind=KIND_NUMBER,
+ default="2",
+ group="Timeouts",
+ ),
+ ProviderField(
+ key="health_retry_seconds",
+ label="Health retry delay (seconds)",
+ kind=KIND_NUMBER,
+ default="30",
+ group="Timeouts",
+ ),
+ ProviderField(
+ key="shutdown_timeout",
+ label="Shutdown timeout (seconds)",
+ kind=KIND_NUMBER,
+ default="30",
+ group="Timeouts",
+ ),
+ ),
+)
diff --git a/integrations/hermes_agent/embedded_backend.py b/integrations/hermes_agent/embedded_backend.py
new file mode 100644
index 00000000..5c805147
--- /dev/null
+++ b/integrations/hermes_agent/embedded_backend.py
@@ -0,0 +1,323 @@
+"""In-process ReMe backend with one dedicated asyncio loop thread."""
+
+from __future__ import annotations
+
+import asyncio
+import concurrent.futures
+import threading
+import time
+
+from enum import Enum
+from typing import Any, Coroutine
+
+from .backend import ReMeBackendError, require_healthy
+
+
+class _State(Enum):
+ NEW = "new"
+ STARTING = "starting"
+ RUNNING = "running"
+ CLOSING = "closing"
+ CLOSED = "closed"
+ FAILED = "failed"
+
+
+class EmbeddedReMeBackend:
+ """Own a ReMe Application and all of its async resources on one event loop."""
+
+ def __init__(
+ self,
+ workspace_dir: str,
+ *,
+ reme_config: str = "default",
+ start_timeout: float = 600.0,
+ ) -> None:
+ self.workspace_dir = workspace_dir
+ self.reme_config = reme_config
+ self.start_timeout = start_timeout
+ self.label = f"embedded:{workspace_dir}"
+ self._state = _State.NEW
+ self._state_lock = threading.RLock()
+ self._operation_lock = threading.Lock()
+ self._loop_ready = threading.Event()
+ self._loop: asyncio.AbstractEventLoop | None = None
+ self._thread: threading.Thread | None = None
+ self._app: Any = None
+ self._app_close_future: concurrent.futures.Future[Any] | None = None
+ self._failure: BaseException | None = None
+
+ @property
+ def state(self) -> str:
+ """Expose lifecycle state for diagnostics and focused tests."""
+ with self._state_lock:
+ return self._state.value
+
+ def start(self, *, deadline: float | None = None) -> None:
+ """Start the loop thread and construct the Application on that loop."""
+ start_deadline = time.monotonic() + self.start_timeout
+ if deadline is not None:
+ start_deadline = min(start_deadline, deadline)
+ with self._state_lock:
+ if self._state is _State.RUNNING:
+ return
+ if self._state is not _State.NEW:
+ detail = f": {self._failure}" if self._failure else ""
+ raise ReMeBackendError(
+ 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,
+ name="reme-embedded-loop",
+ )
+ self._thread.start()
+
+ if not self._loop_ready.wait(timeout=max(0.0, start_deadline - time.monotonic())):
+ error = TimeoutError("Timed out while starting the embedded ReMe event loop")
+ self._fail(error)
+ self._close_after_failed_start(start_deadline)
+ raise ReMeBackendError(
+ "Timed out while starting the embedded ReMe event loop",
+ ) from error
+
+ try:
+ self._submit(
+ self._start_application(),
+ timeout=max(0.0, start_deadline - time.monotonic()),
+ allow_starting=True,
+ )
+ except ReMeBackendError as exc:
+ self._fail(exc)
+ self._close_after_failed_start(start_deadline)
+ raise
+ with self._state_lock:
+ self._state = _State.RUNNING
+
+ def _close_after_failed_start(self, deadline: float) -> None:
+ """Begin cleanup without extending the startup caller's time budget."""
+ try:
+ self.close(timeout=max(0.0, deadline - time.monotonic()))
+ except ReMeBackendError:
+ # close() still requests loop shutdown before reporting a timeout.
+ pass
+
+ def _run_loop(self) -> None:
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ with self._state_lock:
+ self._loop = loop
+ should_run = self._state in {_State.STARTING, _State.RUNNING}
+ self._loop_ready.set()
+ try:
+ if should_run:
+ loop.run_forever()
+ finally:
+ pending = asyncio.all_tasks(loop)
+ for task in pending:
+ task.cancel()
+ if pending:
+ loop.run_until_complete(
+ asyncio.gather(*pending, return_exceptions=True),
+ )
+ loop.run_until_complete(loop.shutdown_asyncgens())
+ loop.close()
+
+ async def _start_application(self) -> None:
+ try:
+ from reme import Application
+ from reme.config import resolve_app_config
+ except ImportError as exc:
+ raise ReMeBackendError(
+ 'Embedded ReMe mode requires the SDK. Install it with: pip install "reme-ai[core]"',
+ ) from exc
+
+ app_config = resolve_app_config(
+ config=self.reme_config,
+ log_config=False,
+ workspace_dir=self.workspace_dir,
+ enable_logo=False,
+ log_to_console=False,
+ log_to_file=False,
+ )
+ app = Application(**app_config)
+ self._app = app
+ try:
+ await app.start()
+ except BaseException:
+ await app.close()
+ self._app = None
+ raise
+
+ async def _close_application(self, app: Any) -> None:
+ """Close one Application and release it only after cleanup completes."""
+ try:
+ await app.close()
+ finally:
+ with self._state_lock:
+ if self._app is app:
+ self._app = None
+
+ def _stop_loop_after_app_close(self, future: concurrent.futures.Future[Any]) -> None:
+ """Finish deferred shutdown after Application cleanup leaves the foreground budget."""
+ try:
+ future.result()
+ except BaseException as exc: # pragma: no cover - retained for diagnostics
+ with self._state_lock:
+ self._failure = self._failure or exc
+ with self._state_lock:
+ loop = self._loop
+ if loop is not None and loop.is_running():
+ loop.call_soon_threadsafe(loop.stop)
+
+ def _fail(self, error: BaseException) -> None:
+ with self._state_lock:
+ self._failure = self._failure or error
+ if self._state not in {_State.CLOSING, _State.CLOSED}:
+ self._state = _State.FAILED
+
+ def _submit(
+ self,
+ coroutine: Coroutine[Any, Any, Any],
+ *,
+ timeout: float,
+ allow_starting: bool = False,
+ ) -> Any:
+ with self._state_lock:
+ allowed = {_State.RUNNING}
+ if allow_starting:
+ allowed.add(_State.STARTING)
+ if self._state not in allowed or self._loop is None or not self._loop.is_running():
+ coroutine.close()
+ raise ReMeBackendError(
+ f"Embedded ReMe is not running (state: {self._state.value})",
+ )
+ loop = self._loop
+ future = asyncio.run_coroutine_threadsafe(coroutine, loop)
+ try:
+ return future.result(timeout=timeout)
+ except concurrent.futures.TimeoutError as exc:
+ future.cancel()
+ raise ReMeBackendError(
+ f"Embedded ReMe operation timed out after {timeout:.1f}s",
+ ) from exc
+ except ReMeBackendError:
+ raise
+ except BaseException as exc:
+ raise ReMeBackendError(str(exc) or type(exc).__name__) from exc
+
+ @staticmethod
+ def _response_dict(response: Any) -> dict[str, Any]:
+ if hasattr(response, "model_dump"):
+ result = response.model_dump()
+ elif isinstance(response, dict):
+ result = dict(response)
+ else:
+ raise ReMeBackendError("ReMe returned an unsupported response object")
+ if result.get("success") is not True:
+ raise ReMeBackendError(
+ str(result.get("answer") or "ReMe action did not report success"),
+ )
+ return result
+
+ async def _run_job(self, name: str, **kwargs: Any) -> dict[str, Any]:
+ if self._app is None:
+ raise ReMeBackendError("Embedded ReMe Application is unavailable")
+ response = await self._app.run_job(name, **kwargs)
+ return self._response_dict(response)
+
+ def health(self, *, timeout: float) -> dict[str, Any]:
+ """Run ReMe's health-check job on the owned loop."""
+ with self._operation_lock:
+ return require_healthy(
+ self._submit(self._run_job("health_check"), timeout=timeout),
+ )
+
+ def search(self, query: str, *, limit: int, timeout: float) -> dict[str, Any]:
+ """Run ReMe's search job on the owned loop."""
+ with self._operation_lock:
+ return self._submit(
+ self._run_job("search", query=query, limit=limit),
+ timeout=timeout,
+ )
+
+ def auto_memory(
+ self,
+ session_id: str,
+ messages: list[dict[str, Any]],
+ *,
+ timeout: float,
+ ) -> dict[str, Any]:
+ """Run ReMe's automatic-memory job on the owned loop."""
+ with self._operation_lock:
+ return self._submit(
+ self._run_job("auto_memory", session_id=session_id, messages=messages),
+ timeout=timeout,
+ )
+
+ def close(self, *, timeout: float) -> None:
+ """Close the Application, stop its loop, and join its thread."""
+ deadline = time.monotonic() + max(0.0, timeout)
+ # A context manager cannot express the bounded wait required by shutdown.
+ acquired = self._operation_lock.acquire( # pylint: disable=consider-using-with
+ timeout=max(0.0, deadline - time.monotonic()),
+ )
+ if not acquired:
+ raise ReMeBackendError(
+ "Timed out waiting for an embedded ReMe operation to finish",
+ )
+ try:
+ self._close_locked(deadline)
+ finally:
+ self._operation_lock.release()
+
+ def _close_locked(self, deadline: float) -> None:
+ """Close while holding the operation lock so jobs cannot overlap shutdown."""
+ close_error: BaseException | None = None
+ defer_loop_stop = False
+ with self._state_lock:
+ if self._state is _State.CLOSED:
+ return
+ if self._state is _State.NEW:
+ self._state = _State.CLOSED
+ return
+ self._state = _State.CLOSING
+ loop = self._loop
+ thread = self._thread
+ app = self._app
+
+ if loop is not None and loop.is_running() and app is not None:
+ remaining = max(0.0, deadline - time.monotonic())
+ with self._state_lock:
+ future = self._app_close_future
+ if future is None:
+ future = asyncio.run_coroutine_threadsafe(
+ self._close_application(app),
+ loop,
+ )
+ self._app_close_future = future
+ try:
+ future.result(timeout=remaining)
+ except concurrent.futures.TimeoutError:
+ defer_loop_stop = True
+ close_error = TimeoutError(
+ "Timed out while closing the embedded ReMe Application",
+ )
+ except BaseException as exc:
+ close_error = exc
+ if defer_loop_stop:
+ future.add_done_callback(self._stop_loop_after_app_close)
+ if not defer_loop_stop and loop is not None and loop.is_running():
+ loop.call_soon_threadsafe(loop.stop)
+ if thread is not None and thread is not threading.current_thread():
+ thread.join(timeout=max(0.0, deadline - time.monotonic()))
+
+ with self._state_lock:
+ self._state = _State.CLOSED if thread is None or not thread.is_alive() else _State.FAILED
+ if self._state is _State.FAILED and self._failure is None:
+ self._failure = TimeoutError(
+ "Timed out while stopping the embedded ReMe event loop",
+ )
+ failure = self._failure if self._state is _State.FAILED else close_error
+ if failure is not None:
+ raise ReMeBackendError(str(failure)) from failure
diff --git a/integrations/hermes_agent/figures/hermes-embedded-recall.png b/integrations/hermes_agent/figures/hermes-embedded-recall.png
new file mode 100644
index 0000000000000000000000000000000000000000..070656f0025b8daeb4a8b6429b09ade021167c9f
GIT binary patch
literal 545612
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
8uvAC=i%#1@3&kR?qQArJAr`f2tTgvlv
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