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. + +![ReMe selected as the active Hermes memory provider](figures/hermes-provider-settings.png) + +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 + +![ReMe selected as the active HTTP memory provider on port 3456](figures/hermes-provider-settings.png) + +### Two independent Hermes sessions + +![The write session and fresh recall session in the Hermes session overview](figures/hermes-http-sessions.png) + +### HTTP mode recall + +![A fresh Hermes session recalls the HTTP-mode verification fact from ReMe](figures/hermes-http-recall.png) + +### File-native durable result + +![The generated Project Juniper Markdown in the English ReMe Studio interface](figures/hermes-reme-daily-note.png) + +### Embedded mode recall + +![A fresh Hermes session recalls the embedded-mode verification fact from ReMe](figures/hermes-embedded-recall.png) + +## 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 中处于 ready 和 active 状态的 ReMe provider](figures/hermes-provider-settings.png) + +配置主路径为 `$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 中使用 3456 端口的 ReMe HTTP provider](figures/hermes-provider-settings.png) + +### 两个相互独立的 Hermes 会话 + +![Hermes 会话总览中的写入会话和全新召回会话](figures/hermes-http-sessions.png) + +### HTTP 模式召回 + +![Hermes 新会话从 ReMe 召回 HTTP 模式验证事实](figures/hermes-http-recall.png) + +### 文件原生的持久化结果 + +![英文 ReMe Studio 中生成的 Project Juniper Markdown](figures/hermes-reme-daily-note.png) + +### Embedded 模式召回 + +![Hermes 新会话从 ReMe 召回 Embedded 模式验证事实](figures/hermes-embedded-recall.png) + +## 常见问题 + +### `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 00000000..070656f0 Binary files /dev/null and b/integrations/hermes_agent/figures/hermes-embedded-recall.png differ diff --git a/integrations/hermes_agent/figures/hermes-http-recall.png b/integrations/hermes_agent/figures/hermes-http-recall.png new file mode 100644 index 00000000..69b29ef1 Binary files /dev/null and b/integrations/hermes_agent/figures/hermes-http-recall.png differ diff --git a/integrations/hermes_agent/figures/hermes-http-sessions.png b/integrations/hermes_agent/figures/hermes-http-sessions.png new file mode 100644 index 00000000..ce79c592 Binary files /dev/null and b/integrations/hermes_agent/figures/hermes-http-sessions.png differ diff --git a/integrations/hermes_agent/figures/hermes-provider-settings.png b/integrations/hermes_agent/figures/hermes-provider-settings.png new file mode 100644 index 00000000..8c0ab24f Binary files /dev/null and b/integrations/hermes_agent/figures/hermes-provider-settings.png differ diff --git a/integrations/hermes_agent/figures/hermes-reme-daily-note.png b/integrations/hermes_agent/figures/hermes-reme-daily-note.png new file mode 100644 index 00000000..67736409 Binary files /dev/null and b/integrations/hermes_agent/figures/hermes-reme-daily-note.png differ diff --git a/integrations/hermes_agent/http_backend.py b/integrations/hermes_agent/http_backend.py new file mode 100644 index 00000000..9e0b68d6 --- /dev/null +++ b/integrations/hermes_agent/http_backend.py @@ -0,0 +1,58 @@ +"""HTTP implementation of the ReMe backend contract.""" + +from __future__ import annotations + +from typing import Any + +from .backend import ReMeBackendError, require_healthy +from .client import ReMeHttpClient, ReMeServiceError + + +class HttpReMeBackend: + """Call an independently managed ReMe action service.""" + + def __init__(self, endpoint: str, *, request_timeout: float) -> None: + self._client = ReMeHttpClient(endpoint, timeout=request_timeout) + self.label = self._client.endpoint + + def start(self, *, deadline: float | None = None) -> None: + """HTTP service lifecycle is managed outside Hermes.""" + del deadline + + def _call( + self, + action: str, + payload: dict[str, Any] | None, + *, + timeout: float, + ) -> dict[str, Any]: + try: + return self._client.call(action, payload, timeout=timeout) + except (ReMeServiceError, ValueError) as exc: + raise ReMeBackendError(str(exc)) from exc + + def health(self, *, timeout: float) -> dict[str, Any]: + """Call the action service health check.""" + return require_healthy(self._call("health_check", None, timeout=timeout)) + + def search(self, query: str, *, limit: int, timeout: float) -> dict[str, Any]: + """Search through the HTTP action service.""" + return self._call("search", {"query": query, "limit": limit}, timeout=timeout) + + def auto_memory( + self, + session_id: str, + messages: list[dict[str, Any]], + *, + timeout: float, + ) -> dict[str, Any]: + """Submit a completed turn through the HTTP action service.""" + return self._call( + "auto_memory", + {"session_id": session_id, "messages": messages}, + timeout=timeout, + ) + + def close(self, *, timeout: float) -> None: + """Release no resources because urllib calls are request-scoped.""" + del timeout diff --git a/integrations/hermes_agent/plugin.yaml b/integrations/hermes_agent/plugin.yaml index 74e68ec2..5ac57b75 100644 --- a/integrations/hermes_agent/plugin.yaml +++ b/integrations/hermes_agent/plugin.yaml @@ -1,4 +1,11 @@ name: reme -version: 0.1.0 -description: "ReMe file-native long-term memory for Hermes Agent." +version: 0.2.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 +license: Apache-2.0 +tags: [memory-provider, local-first, long-term-memory] pip_dependencies: [] diff --git a/tests/unit/test_hermes_agent_integration.py b/tests/unit/test_hermes_agent_integration.py new file mode 100644 index 00000000..6c0d37a5 --- /dev/null +++ b/tests/unit/test_hermes_agent_integration.py @@ -0,0 +1,569 @@ +"""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 + +from __future__ import annotations + +import sys +import types +import asyncio +import importlib +import threading +import time + +from dataclasses import dataclass +from pathlib import Path + +import pytest + +_PLUGIN_PARENT = Path(__file__).resolve().parents[2] / "integrations" +if str(_PLUGIN_PARENT) not in sys.path: + sys.path.insert(0, str(_PLUGIN_PARENT)) + + +try: + import agent.memory_provider # type: ignore[import-not-found] # noqa: F401 +except ImportError: + agent_module = types.ModuleType("agent") + memory_provider_module = types.ModuleType("agent.memory_provider") + + class MemoryProvider: + """Minimal Hermes contract used when hermes-agent is not installed.""" + + @dataclass(frozen=True) + class RecallStatus: + provider_label: str + count: int + glyph: str = "🧠" + + memory_provider_module.MemoryProvider = MemoryProvider + memory_provider_module.RecallStatus = RecallStatus + agent_module.memory_provider = memory_provider_module + sys.modules["agent"] = agent_module + sys.modules["agent.memory_provider"] = memory_provider_module + +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") +CLIENT_MODULE = importlib.import_module("hermes_agent.client") + +ReMeMemoryProvider = PLUGIN_MODULE.ReMeMemoryProvider +ReMeBackendError = BACKEND_MODULE.ReMeBackendError +ReMeConfig = CONFIG_MODULE.ReMeConfig +ReMeConfigError = CONFIG_MODULE.ReMeConfigError +config_path = CONFIG_MODULE.config_path +load_config = CONFIG_MODULE.load_config +parse_config = CONFIG_MODULE.parse_config +save_config = CONFIG_MODULE.save_config +EmbeddedReMeBackend = EMBEDDED_MODULE.EmbeddedReMeBackend +ReMeHttpClient = CLIENT_MODULE.ReMeHttpClient +scoped_session_id = PLUGIN_MODULE._scoped_session_id + + +def test_config_defaults_to_http(tmp_path): + config = load_config(tmp_path) + + assert config.mode == "http" + assert config.endpoint == "http://127.0.0.1:2333" + + +def test_provider_schema_exposes_mode_specific_and_advanced_fields(): + fields = {field["key"]: field for field in ReMeMemoryProvider().get_config_schema()} + + assert set(fields) == { + "mode", + "endpoint", + "workspace_dir", + "reme_config", + "recall_limit", + "recall_timeout", + "request_timeout", + "health_timeout", + "health_retry_seconds", + "shutdown_timeout", + } + assert fields["endpoint"]["when"] == {"mode": "http"} + assert fields["workspace_dir"]["when"] == {"mode": "embedded"} + + +def test_current_config_precedes_legacy(tmp_path): + (tmp_path / "reme.json").write_text( + '{"endpoint": "http://legacy:1"}', + encoding="utf-8", + ) + current = config_path(tmp_path) + current.parent.mkdir() + current.write_text('{"endpoint": "http://current:2"}', encoding="utf-8") + + assert load_config(tmp_path).endpoint == "http://current:2" + + +def test_sparse_dashboard_config_inherits_legacy_values(tmp_path): + (tmp_path / "reme.json").write_text( + '{"endpoint": "http://legacy:2444", "recall_limit": 3}', + encoding="utf-8", + ) + current = config_path(tmp_path) + current.parent.mkdir() + current.write_text('{"recall_limit": 7}', encoding="utf-8") + + config = load_config(tmp_path) + + assert config.endpoint == "http://legacy:2444" + assert config.recall_limit == 7 + + +def test_embedded_config_normalizes_workspace(tmp_path): + config = parse_config( + {"mode": " EMBEDDED ", "workspace_dir": str(tmp_path / "workspace")}, + hermes_home=tmp_path, + ) + + assert config.mode == "embedded" + assert config.workspace_dir == str((tmp_path / "workspace").absolute()) + + +def test_embedded_config_requires_workspace(tmp_path): + with pytest.raises(ReMeConfigError, match="workspace_dir"): + parse_config({"mode": "embedded"}, hermes_home=tmp_path) + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), float("-inf")]) +def test_config_rejects_non_finite_timeouts(tmp_path, value): + with pytest.raises(ReMeConfigError, match="positive number"): + parse_config({"request_timeout": value}, hermes_home=tmp_path) + + +@pytest.mark.parametrize( + "endpoint", + [ + "http://user:secret@127.0.0.1:2333", + "http://127.0.0.1:2333?token=secret", + "http://127.0.0.1:2333#fragment", + "http://127.0.0.1:invalid", + ], +) +def test_config_and_client_reject_unsafe_endpoint_shapes(tmp_path, endpoint): + with pytest.raises(ReMeConfigError, match="absolute http"): + parse_config({"endpoint": endpoint}, hermes_home=tmp_path) + with pytest.raises(ValueError, match="absolute http"): + ReMeHttpClient(endpoint, 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) + + assert saved.recall_limit == 7 + assert path.is_file() + assert path.stat().st_mode & 0o777 == 0o600 + + +class _FakeResponse: + def __init__(self, answer="", metadata=None, success=True): + self.answer = answer + self.metadata = metadata or {} + self.success = success + + def model_dump(self): + return { + "answer": self.answer, + "metadata": self.metadata, + "success": self.success, + } + + +class _FakeApplication: + instances = [] + + def __init__(self, **config): + self.config = config + self.started = False + self.closed = False + self.calls = [] + self.__class__.instances.append(self) + + async def start(self): + self.started = True + + async def close(self): + self.closed = True + + async def run_job(self, name, **kwargs): + self.calls.append((name, kwargs)) + if name == "health_check": + return _FakeResponse(metadata={"health": {"healthy": True}}) + return _FakeResponse(answer=name, metadata={"counts": {"returned": 1}}) + + +def test_embedded_backend_owns_application_lifecycle(monkeypatch, tmp_path): + import reme + import reme.config + + _FakeApplication.instances.clear() + monkeypatch.setattr(reme, "Application", _FakeApplication) + monkeypatch.setattr( + reme.config, + "resolve_app_config", + lambda **kwargs: { + "workspace_dir": kwargs["workspace_dir"], + "marker": kwargs["config"], + }, + ) + backend = EmbeddedReMeBackend(str(tmp_path), start_timeout=2) + + backend.start() + assert backend.health(timeout=2)["success"] is True + assert backend.search("needle", limit=3, timeout=2)["answer"] == "search" + backend.auto_memory("session", [{"role": "user", "content": "hello"}], timeout=2) + backend.close(timeout=2) + + app = _FakeApplication.instances[0] + assert app.started is True + assert app.closed is True + assert app.config == { + "workspace_dir": str(tmp_path), + "marker": "default", + } + assert [name for name, _ in app.calls] == ["health_check", "search", "auto_memory"] + assert backend.state == "closed" + + +class _FakeBackend: + label = "fake" + + def __init__(self): + self.writes = [] + self.closed = False + + def start(self): + return None + + def health(self, *, timeout): + del timeout + return {"success": True, "metadata": {"health": {"healthy": True}}} + + def search(self, query, *, limit, timeout): + del query, limit, timeout + return { + "success": True, + "answer": " remembered ", + "metadata": {"counts": {"returned": 2}}, + } + + def auto_memory(self, session_id, messages, *, timeout): + del timeout + self.writes.append((session_id, messages)) + return {"success": True} + + def close(self, *, timeout): + del timeout + self.closed = True + + +def test_provider_selects_backend_recalls_and_writes(monkeypatch, tmp_path): + backend = _FakeBackend() + monkeypatch.setattr(PLUGIN_MODULE, "load_config", lambda home=None: ReMeConfig()) + monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", lambda config: backend) + provider = ReMeMemoryProvider() + provider.initialize("session/one", hermes_home=tmp_path, agent_identity="work") + + assert provider.prefetch("project decision") == "remembered" + assert provider.recall_status().count == 2 + provider.sync_turn("hello", "hi") + provider.shutdown() + + assert len(backend.writes) == 1 + assert backend.writes[0][0].startswith("hermes-work-session-one-") + assert backend.closed is True + + +def test_session_scope_distinguishes_profiles_and_ambiguous_names(): + assert scoped_session_id("profile/a", "session") != scoped_session_id( + "profile-a", + "session", + ) + assert scoped_session_id("profile", "session/a") != scoped_session_id( + "profile", + "session-a", + ) + + +def test_reinitialize_closes_previous_backend(monkeypatch, tmp_path): + backends = [_FakeBackend(), _FakeBackend()] + monkeypatch.setattr(PLUGIN_MODULE, "load_config", lambda home=None: ReMeConfig()) + monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", lambda config: backends.pop(0)) + provider = ReMeMemoryProvider() + + provider.initialize("first", hermes_home=tmp_path) + first = provider._backend + provider.initialize("second", hermes_home=tmp_path) + + assert first is not None and first.closed is True + assert provider._backend is not first + provider.shutdown() + + +def test_provider_failure_does_not_escape_model_path(monkeypatch, tmp_path): + backend = _FakeBackend() + backend.search = lambda *args, **kwargs: (_ for _ in ()).throw( + ReMeBackendError("offline"), + ) + monkeypatch.setattr(PLUGIN_MODULE, "load_config", lambda home=None: ReMeConfig()) + monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", lambda config: backend) + provider = ReMeMemoryProvider() + provider.initialize("session", hermes_home=tmp_path) + + assert provider.prefetch("query") == "" + assert provider.recall_status() is None + provider.shutdown() + + +def test_provider_reports_embedded_workspace_for_hermes_backup(monkeypatch, tmp_path): + workspace = tmp_path / "workspace" + monkeypatch.setattr( + PLUGIN_MODULE, + "load_config", + lambda home=None: ReMeConfig(mode="embedded", workspace_dir=str(workspace)), + ) + + assert ReMeMemoryProvider().backup_paths() == [str(workspace)] + + +def test_backend_creation_is_serialized(monkeypatch): + provider = ReMeMemoryProvider() + provider._config = ReMeConfig() + created = [] + + def factory(config): + del config + time.sleep(0.05) + backend = _FakeBackend() + created.append(backend) + return backend + + monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", factory) + gate = threading.Barrier(3) + results = [] + + def ensure(): + gate.wait() + results.append(provider._ensure_backend()) + + threads = [threading.Thread(target=ensure) for _ in range(2)] + for thread in threads: + thread.start() + gate.wait() + for thread in threads: + thread.join(timeout=2) + + assert results == [True, True] + assert len(created) == 1 + provider.shutdown() + + +def test_shutdown_defers_close_until_inflight_recall_finishes(): + entered = threading.Event() + release = threading.Event() + + class BlockingBackend(_FakeBackend): + def search(self, query, *, limit, timeout): + del query, limit, timeout + entered.set() + release.wait(timeout=2) + return {"success": True, "answer": "remembered", "metadata": {}} + + backend = BlockingBackend() + provider = ReMeMemoryProvider() + provider._config = ReMeConfig() + provider._backend = backend + provider._backend_available = True + provider._shutdown_timeout = 0.05 + recall = threading.Thread(target=provider.prefetch, args=("query",)) + recall.start() + assert entered.wait(timeout=1) + + provider.shutdown() + assert backend.closed is False + release.set() + recall.join(timeout=2) + + assert recall.is_alive() is False + assert backend.closed is True + + +def test_shutdown_drains_all_accepted_writes_before_closing_backend(): + entered = threading.Event() + release = threading.Event() + + class BlockingBackend(_FakeBackend): + def auto_memory(self, session_id, messages, *, timeout): + del timeout + self.writes.append((session_id, messages)) + if len(self.writes) == 1: + entered.set() + release.wait(timeout=2) + + backend = BlockingBackend() + provider = ReMeMemoryProvider() + provider._config = ReMeConfig() + provider._backend = backend + provider._backend_available = True + provider._shutdown_timeout = 1 + for index in range(3): + provider.sync_turn(f"user {index}", f"assistant {index}", session_id=f"session-{index}") + assert entered.wait(timeout=1) + + shutdown = threading.Thread(target=provider.shutdown) + shutdown.start() + deadline = time.monotonic() + 1 + while not provider._shutdown_started and time.monotonic() < deadline: + time.sleep(0.001) + assert provider._shutdown_started is True + release.set() + shutdown.join(timeout=2) + + assert shutdown.is_alive() is False + assert len(backend.writes) == 3 + assert backend.closed is True + + +def test_recall_timeout_includes_waiting_for_background_write(): + write_entered = threading.Event() + write_release = threading.Event() + search_entered = threading.Event() + + class BlockingBackend(_FakeBackend): + def auto_memory(self, session_id, messages, *, timeout): + del session_id, messages, timeout + write_entered.set() + write_release.wait(timeout=2) + + def search(self, query, *, limit, timeout): + del query, limit, timeout + search_entered.set() + return {"success": True, "answer": "remembered", "metadata": {}} + + backend = BlockingBackend() + provider = ReMeMemoryProvider() + provider._config = ReMeConfig() + provider._backend = backend + provider._backend_available = True + provider._recall_timeout = 0.05 + provider.sync_turn("user", "assistant", session_id="session") + assert write_entered.wait(timeout=1) + + started = time.monotonic() + assert provider.prefetch("query") == "" + elapsed = time.monotonic() - started + + assert elapsed < 0.2 + assert search_entered.is_set() is False + write_release.set() + provider.shutdown() + + +def test_recall_timeout_includes_embedded_startup_failure_cleanup(monkeypatch, tmp_path): + import reme + import reme.config + + class SlowApplication: + instances = [] + + def __init__(self, **config): + del config + self.closed = threading.Event() + self.thread_pool = object() + self.__class__.instances.append(self) + + async def start(self): + time.sleep(0.25) + + async def close(self): + await asyncio.sleep(0.4) + self.thread_pool = None + self.closed.set() + + monkeypatch.setattr(reme, "Application", SlowApplication) + monkeypatch.setattr(reme.config, "resolve_app_config", lambda **kwargs: kwargs) + backend = EmbeddedReMeBackend(str(tmp_path), start_timeout=1) + monkeypatch.setattr(PLUGIN_MODULE, "_backend_for", lambda config: backend) + provider = ReMeMemoryProvider() + provider._config = ReMeConfig( + mode="embedded", + workspace_dir=str(tmp_path), + request_timeout=1, + recall_timeout=0.15, + shutdown_timeout=0.6, + ) + provider._recall_timeout = 0.15 + provider._shutdown_timeout = 0.6 + + started = time.monotonic() + assert provider.prefetch("query") == "" + elapsed = time.monotonic() - started + + assert elapsed < 0.25 + assert backend._thread is not None + backend._thread.join(timeout=1) + assert backend._thread.is_alive() is False + app = SlowApplication.instances[0] + assert app.closed.is_set() + assert app.thread_pool is None + + +def test_embedded_start_timeout_closes_real_application_resources(monkeypatch, tmp_path): + import reme + import reme.config + + from reme.application import Application + from reme.components import BaseComponent + + component_closed = threading.Event() + + class SlowComponent(BaseComponent): + component_type = "slow_test" + + async def _start(self): + time.sleep(0.25) + + async def _close(self): + component_closed.set() + + app = Application( + workspace_dir=str(tmp_path), + service={"backend": "cli"}, + thread_pool_max_workers=1, + enable_logo=False, + log_to_console=False, + log_to_file=False, + ) + component = SlowComponent(name="slow", app_context=app.context) + app.context.components["slow_test"] = {"slow": component} + monkeypatch.setattr(reme, "Application", lambda **config: app) + monkeypatch.setattr(reme.config, "resolve_app_config", lambda **kwargs: kwargs) + backend = EmbeddedReMeBackend(str(tmp_path), start_timeout=1) + + started = time.monotonic() + with pytest.raises(ReMeBackendError, match="timed out"): + backend.start(deadline=started + 0.15) + elapsed = time.monotonic() - started + + assert elapsed < 0.25 + assert backend._thread is not None + backend._thread.join(timeout=1) + assert backend._thread.is_alive() is False + assert component_closed.is_set() + assert component.is_started is False + assert app.context.thread_pool is None + + +def test_shutdown_discard_keeps_sentinel_for_inflight_writer(): + provider = ReMeMemoryProvider() + provider._write_queue.put({"session_id": "queued"}) + provider._write_queue.put(None) + + assert provider._discard_queued_writes() == 1 + assert provider._write_queue.get_nowait() is None