diff --git a/frontend/src/content/en/harness/memory.mdx b/frontend/src/content/en/harness/memory.mdx index c3fcd5149b..d4c5813bf9 100644 --- a/frontend/src/content/en/harness/memory.mdx +++ b/frontend/src/content/en/harness/memory.mdx @@ -38,6 +38,172 @@ Memory has two operation modes: In tool mode, the four `memory_*` tool names are reserved. If an MCP server or custom tool already uses `memory_search`, `memory_add`, `memory_update`, or `memory_delete`, the existing tool keeps that name and the colliding memory tool is skipped with a warning. +## Memory Manager: the pluggable memory manager + +At the core of the memory feature is a **backend-neutral `MemoryManager`** (defined in `backend/packages/harness/deerflow/agents/memory/manager.py`). It abstracts *what memory can do* into a stable contract, while *where memory lives and how it is extracted and retrieved* is left to swappable backends. + +### Design philosophy + +The design goal is a single sentence: **swapping the memory backend requires zero changes anywhere else in DeerFlow**. + +- **The contract says what, not how.** `get_context()` only has to return injection-ready text — whether that comes from a local file load or a remote retrieval call is the backend's own business. The contract does not even assume memory is a set of "facts". +- **Tiered by implementation cost.** Only two methods are mandatory (write via `add`, read-and-inject via `get_context`); every other management operation (search, clear, import, fact CRUD, etc.) ships with a default "unsupported" implementation, so a backend exposes exactly as much capability as it implements. +- **Fail loud, never silently.** Memory is persistent data. A misconfigured backend or a corrupted file raises directly (e.g. `ValueError` at build time, HTTP 409 on write conflicts) instead of quietly falling back to another store — which would silently write your data to the wrong place. +- **Host and backend decoupled.** Host capabilities (Langfuse spans, the default LLM, extraction metrics) are injected as hooks that backends consume as needed, so the backend package itself never depends on a DeerFlow-specific concept and can be shipped independently. + +The `MemoryManager` contract at a glance: + +| Tier | Methods | Purpose | +| --- | --- | --- | +| Mandatory | `add` / `get_context` | Queue conversations for write (debounced) / return injection-ready memory text | +| Management ops | `search`, `get_memory`, `clear_memory`, `import_memory`, `create_fact` / `update_fact` / `delete_fact`, `add_nowait`, `cancel_by_agent`, `shutdown_flush` | Search & inspect, clear & import, fact CRUD for tool mode, emergency flush before summarization, cancel pending extraction, bounded drain on graceful shutdown | +| Optional hooks | `warm`, `reload_memory`, `on_pre_compress` / `on_turn_start`, plus async variants | Startup warm-up, cache reload, future extension points | + +### How to use it + +Select a backend via `memory.manager_class` in `config.yaml`; backend-private settings all live under `memory.backend_config`: + +```yaml +memory: + enabled: true + mode: middleware # middleware (default) | tool (model calls memory_* tools directly) + manager_class: deermem # backend selector: deermem | mem0 | honcho | openviking | noop + backend_config: {} # this backend's own knobs, interpreted by the backend +``` + +Built-in backends compared: + +| `manager_class` | Type | Positioning | +| --- | --- | --- | +| `deermem` | Local (default) | The bundled full memory implementation: Markdown fact storage, summaries, full-text retrieval, and fact lifecycle management | +| `openviking` | Remote | Connects to an independent OpenViking memory server, recalling through the official `langchain-openviking` adapter | +| `mem0` / `honcho` | Remote | Adapters for the Mem0 / Honcho memory products | +| `noop` | Local | Empty implementation: memory call sites stay, nothing happens | + +Two things to know: + +- **Tool mode requires search support.** `mode: tool` needs the `memory_search` tool, so the backend must implement `search()`; a backend without it fails at startup rather than silently returning empty results at runtime. +- **Custom backends are drop-in.** Create a package under `deerflow/agents/memory/backends//` exposing `MANAGER_CLASS = `, set `manager_class: `, done. Dotted import paths (`pkg.mod:Cls`) also work. + +### How deermem is integrated + +deermem is the default backend and implements the full contract: + +- The factory `get_memory_manager()` reads `manager_class: deermem` and passes `backend_config` plus a set of **host hooks** (Langfuse tracing callbacks, the default extraction LLM, hidden-message filtering, extraction metrics) to `DeerMem.from_config()`, which assembles the instance. +- Storage is bucketed per `(user, agent)`: `memory.json` holds only shared summaries and revision metadata, every fact is its own Markdown file (with YAML front matter), and a SQLite FTS5 full-text index backs `memory_search`. +- Writes are queued by `MemoryMiddleware` calling `add()` after each turn (debounced and batched); summarization triggers an `add_nowait()` emergency flush so compacted content is never lost. +- On top of that sit opt-in fact-governance capabilities: write-side near-duplicate merging (`fact_dedup_enabled`), staleness review (`staleness_review_enabled`), consolidation (`consolidation_enabled`), capacity eviction policy (`fact_eviction_policy: hybrid-v1`), and relevance-aware retrieval (`retrieval_relevance_enabled`). + +### How openviking is integrated + +OpenViking is a remote memory backend with a crisp responsibility split: **DeerFlow owns capture timing, the recall query, and the transcript cursor; the `langchain-openviking` package owns transport, message conversion, batching, and Session commits.** + +```yaml +memory: + enabled: true + injection_enabled: true + manager_class: openviking + mode: middleware # middleware mode is recommended + backend_config: + base_url: http://openviking:1933 + owner_user_id: default # use default when auth is disabled + api_key_env: OPENVIKING_API_KEY + failure_policy: + read: fail_open # read failure returns empty results (fail_closed aborts) + write: log_and_drop + retrieval: + top_k: 8 + score_threshold: 0.25 + max_injection_chars: 12000 +``` + +Integration notes: + +- **One API key is bound to one DeerFlow owner**; accessing another owner is rejected. Keep the USER key in the server environment (`api_key_env`), never in `config.yaml`. +- **One DeerFlow thread maps to one stable OpenViking Session**; bounded hash-only cursors live under `{storage_path}/openviking/sessions/`. +- This version supports one user with one key in middleware mode; multi-user key provisioning is out of scope. See `docs/OPENVIKING.md` for the full boundary and startup guide. + +## Implementing your own memory backend + +Hooking up your own memory system (a database, an internal service, or a new product) takes four steps — nothing else in DeerFlow changes. + +### Step 1: Create the backend package + +Create a new directory named after your backend under `backend/packages/harness/deerflow/agents/memory/backends/`. As long as its `__init__.py` exposes a `MANAGER_CLASS` attribute (a `MemoryManager` subclass), the factory discovers and registers it automatically — the folder name is the `manager_class` value in config. + +### Step 2: Implement the contract + +Create `mybackend/manager.py`: + +```python +from deerflow.agents.memory.manager import MemoryManager + + +class MyMemoryManager(MemoryManager): + # Declare search support (True only if you actually override search(); + # a mismatch between the flag and the implementation fails at + # instantiation, so the two can never drift apart) + supports_search = True + + @classmethod + def from_config(cls, backend_config, *, mode="middleware", **host_hooks): + # backend_config: the dict from memory.backend_config, yours to interpret + # host_hooks: optional host-provided capabilities (see below); ignore if unused + return cls(backend_config=backend_config, mode=mode) + + def add(self, thread_id, messages, *, agent_name=None, user_id=None, trace_id=None): + """Mandatory: queue conversations for write (filtering/extraction is your private concern).""" + + def get_context(self, user_id, *, agent_name=None, thread_id=None, query=None): + """Mandatory: return injection-ready text (how you retrieve and format is up to you).""" + + def search(self, query, top_k=5, *, user_id=None, agent_name=None, category=None): + """Optional: return facts ranked by relevance (required for tool mode).""" +``` + +Only `from_config` + `add` + `get_context` are mandatory. Everything else is opt-in with sensible defaults: + +| Capability you want | Method to override | Notes | +| --- | --- | --- | +| Tool mode (`memory_search` etc.) | `search()`, and set `supports_search = True` | Flag/implementation consistency is enforced at instantiation | +| Inspect / clear / import memory | `get_memory()` / `clear_memory()` / `import_memory()` | Unimplemented ops raise "not supported" | +| Fact-level CRUD | `create_fact()` / `update_fact()` / `delete_fact()` | Backs `memory_add` / `memory_update` / `memory_delete` in tool mode | +| Startup warm-up (indexing, encoders, …) | `warm()`, returning `True` / `False` / `None` | `None` means nothing to warm; the log says "skipped" honestly | +| Releasing connections and other resources | `close()` | Called on graceful shutdown | +| Cache reload | `reload_memory()` | For caching backends after out-of-band edits | + +### Step 3: Declare failure and conflict semantics + +- Read-failure policy is declared via `read_failures_are_fatal_for_config()`: permissive by default (failures return empty text); setting `backend_config.failure_policy.read: fail_closed` makes callers abort instead of degrading. +- Raise `MemoryConflictError` for lost write races (the Gateway maps it to HTTP 409) and `MemoryCorruptionError` for unreadable storage (HTTP 500). Never rely on exception-text matching. + +### Step 4: Enable it + +```yaml +memory: + manager_class: mybackend # i.e. backends/ +``` + +You can also skip the `backends/` directory and use a dotted path directly: `manager_class: mypackage.mymodule:MyMemoryManager`. Either way, a resolution failure raises `ValueError` at startup — no silent fallback. + +### Available host hooks + +`from_config` receives a set of host-default capabilities in `**host_hooks` — consume what you need: + +| Hook | What it does | +| --- | --- | +| `callbacks` | A `MemoryCallbacks` instance: fired around your LLM calls; the default surfaces memory extraction as a dedicated Langfuse span | +| `host_llm_factory` | Host default model factory: use it for zero-config extraction when your backend has no dedicated model | +| `should_keep_hidden_message` | Hidden-message filter: by default only messages carrying a human clarification response enter memory | +| `trace_context_manager` | Trace context manager | +| `extraction_callback` | Extraction metrics callback (token usage, confidence filtering, rejection rates) | + +Consuming none of them is perfectly legal (the `noop` backend uses none). + +### Testing conventions + +Backend-specific tests follow the naming convention `backend/tests/test__memory_backend.py`; the existing deermem / mem0 / honcho / openviking tests are good templates. + ## Configuration ```yaml @@ -145,6 +311,8 @@ You can change the storage path with the `storage_path` field. Relative paths ar ## Custom storage backend +> This section is about **replacing DeerMem's internal storage layer** (lighter-weight — only changes *where* data lives). To hook up a fully independent memory system, see [Implementing your own memory backend](#implementing-your-own-memory-backend) above. + The `storage_class` field allows you to replace the default file-based storage with a custom implementation. Any class that extends `MemoryStorage` (`deerflow.agents.memory.backends.deermem.deermem.core.storage`) and implements `load()`, `reload()`, and `save()` methods can be used: ```yaml diff --git a/frontend/src/content/zh/harness/memory.mdx b/frontend/src/content/zh/harness/memory.mdx index 20c9a9b885..ef83789fa7 100644 --- a/frontend/src/content/zh/harness/memory.mdx +++ b/frontend/src/content/zh/harness/memory.mdx @@ -37,6 +37,171 @@ import { Callout } from "nextra/components"; 在工具模式中,四个 `memory_*` 工具名是保留名。如果 MCP 服务或自定义工具已经使用 `memory_search`、`memory_add`、`memory_update` 或 `memory_delete`,已有工具会保留该名称,冲突的记忆工具会被跳过并记录告警。 +## Memory Manager:可插拔的记忆管理器 + +记忆功能的核心是一个**后端中立的 `MemoryManager`**(定义在 `backend/packages/harness/deerflow/agents/memory/manager.py`)。它把“记忆能做什么”抽象成一份稳定的契约,而“记忆存在哪、怎么提取、怎么检索”则交由可替换的后端实现。 + +### 设计思路 + +设计目标只有一个:**换记忆后端时,DeerFlow 其余代码零改动**。 + +- **契约只管“做什么”,不管“怎么做”**。`get_context()` 只要求返回一段可注入提示的文本——是本地文件加载还是远程检索,由后端自行决定;契约甚至不假设记忆一定是“事实”的集合。 +- **按实现成本分层**。只有两个方法是必须实现的(写入 `add`、读取注入 `get_context`);其余管理操作(搜索、清空、导入、事实增删改等)都带默认的“不支持”实现,后端实现多少能力就暴露多少能力。 +- **失败要响亮,不要静默**。记忆是持久化数据,配错后端、写坏文件时系统会直接报错(如构建时抛出 `ValueError`、写冲突返回 HTTP 409),绝不悄悄回退到另一个存储——那等于把你的数据写到了错误的地方。 +- **宿主与后端解耦**。追踪(Langfuse span)、默认 LLM、提取指标等宿主能力以“钩子”形式注入后端,后端按需消费,因此后端代码包本身不依赖任何 DeerFlow 特有概念,可以独立发布。 + +`MemoryManager` 契约一览: + +| 分层 | 方法 | 作用 | +| --- | --- | --- | +| 必须实现 | `add` / `get_context` | 写入对话(防抖排队)/ 返回注入就绪的记忆文本 | +| 管理操作 | `search`、`get_memory`、`clear_memory`、`import_memory`、`create_fact` / `update_fact` / `delete_fact`、`add_nowait`、`cancel_by_agent`、`shutdown_flush` | 搜索与查看、清空与导入、工具模式的事实增删改、摘要压缩前的紧急冲刷、取消挂起的提取、优雅停机排水 | +| 可选钩子 | `warm`、`reload_memory`、`on_pre_compress` / `on_turn_start` 及 async 变体 | 启动预热、缓存重载、面向未来的扩展点 | + +### 怎么使用 + +通过 `config.yaml` 的 `memory.manager_class` 选择后端,后端私有参数全部放在 `memory.backend_config`: + +```yaml +memory: + enabled: true + mode: middleware # middleware(默认)| tool(模型直接调用 memory_* 工具) + manager_class: deermem # 后端选择:deermem | mem0 | honcho | openviking | noop + backend_config: {} # 该后端自己的参数,由后端自行解释 +``` + +内置后端对比: + +| `manager_class` | 类型 | 定位 | +| --- | --- | --- | +| `deermem` | 本地(默认) | 自带的完整记忆实现:Markdown 事实存储、摘要、全文检索与事实生命周期管理 | +| `openviking` | 远程 | 接入独立的 OpenViking 记忆服务器,通过官方 `langchain-openviking` 适配器回记 | +| `mem0` / `honcho` | 远程 | 接入 Mem0 / Honcho 记忆产品的适配器 | +| `noop` | 本地 | 空实现:保留记忆调用点但什么都不做 | + +两点使用须知: + +- **工具模式要求后端支持搜索**。`mode: tool` 需要 `memory_search` 工具,因此后端必须实现 `search()`;不支持的后端会在启动时直接报错,而不是运行时静默返回空结果。 +- **自定义后端即插即用**。在 `deerflow/agents/memory/backends//` 下创建包并暴露 `MANAGER_CLASS = <你的 MemoryManager 子类>`,把 `manager_class` 设为 `` 即可;也支持填点号导入路径(`pkg.mod:Cls`)。 + +### deermem 是怎么接入的 + +deermem 是默认后端,完整实现了契约的全部能力: + +- 工厂 `get_memory_manager()` 读取 `manager_class: deermem`,把 `backend_config` 连同一组**宿主钩子**(Langfuse 追踪回调、默认提取 LLM、隐藏消息过滤、提取指标回调)一起传给 `DeerMem.from_config()` 装配实例。 +- 存储按 `(用户, Agent)` 双维度分桶:`memory.json` 只存共享摘要与修订元数据,每条事实是独立的 Markdown 文件(带 YAML front matter),并维护 SQLite FTS5 全文索引供 `memory_search` 使用。 +- 写入侧由 `MemoryMiddleware` 在每轮结束后调用 `add()` 排队(防抖批量处理),摘要压缩前通过 `add_nowait()` 紧急冲刷,避免被压缩掉的内容丢失。 +- 在此之上提供可选的事实治理能力(按需开启):写侧近重复去重(`fact_dedup_enabled`)、陈旧性复核(`staleness_review_enabled`)、合并巩固(`consolidation_enabled`)、容量淘汰策略(`fact_eviction_policy: hybrid-v1`)以及相关性感知检索(`retrieval_relevance_enabled`)。 + +### openviking 是怎么接入的 + +OpenViking 是远程记忆后端,职责边界划分得很清楚:**DeerFlow 负责捕获时机、召回查询和会话游标;`langchain-openviking` 包负责传输、消息转换、批处理与 Session 提交**。 + +```yaml +memory: + enabled: true + injection_enabled: true + manager_class: openviking + mode: middleware # 建议保持 middleware 模式 + backend_config: + base_url: http://openviking:1933 + owner_user_id: default # 未开启认证时用 default + api_key_env: OPENVIKING_API_KEY + failure_policy: + read: fail_open # 读取失败时返回空结果(fail_closed 则中断) + write: log_and_drop + retrieval: + top_k: 8 + score_threshold: 0.25 + max_injection_chars: 12000 +``` + +接入要点: + +- **一个 API Key 绑定一个 DeerFlow 拥有者**,访问其他用户会被拒绝;USER Key 放在服务端环境变量(`api_key_env`),不要写进 `config.yaml`。 +- **一个 DeerFlow 线程映射到一个稳定的 OpenViking Session**,会话游标以哈希形式存于 `{storage_path}/openviking/sessions/` 下。 +- 当前版本支持单用户单 Key、middleware 模式;多用户 Key 分发不在本版本范围内。完整边界与启动指南见 `docs/OPENVIKING.md`。 + +## 实现自己的记忆后端 + +接入你自己的记忆系统(一个数据库、一个内部服务、或一个新产品)只需要四步,DeerFlow 其余代码一行都不用改。 + +### 第 1 步:创建后端包 + +在 `backend/packages/harness/deerflow/agents/memory/backends/` 下新建一个以你的后端命名的目录,只要这个包在 `__init__.py` 里暴露 `MANAGER_CLASS` 属性(一个 `MemoryManager` 子类),它就会被工厂自动发现并注册——目录名就是配置里 `manager_class` 的值。 + +### 第 2 步:实现契约 + +新建 `mybackend/manager.py`: + +```python +from deerflow.agents.memory.manager import MemoryManager + + +class MyMemoryManager(MemoryManager): + # 声明支持搜索(只有真正覆盖了 search() 才能设 True, + # 两者不一致会在实例化时报错,防止声明与实现脱节) + supports_search = True + + @classmethod + def from_config(cls, backend_config, *, mode="middleware", **host_hooks): + # backend_config: 来自 memory.backend_config 的字典,由你自行解释 + # host_hooks: 宿主注入的可选能力(见下文),不需要就忽略 + return cls(backend_config=backend_config, mode=mode) + + def add(self, thread_id, messages, *, agent_name=None, user_id=None, trace_id=None): + """必须实现:把对话排队写入(怎么过滤、怎么提取是你的私事)""" + + def get_context(self, user_id, *, agent_name=None, thread_id=None, query=None): + """必须实现:返回注入就绪的文本(怎么检索、怎么排版由你决定)""" + + def search(self, query, top_k=5, *, user_id=None, agent_name=None, category=None): + """可选:返回按相关性排序的事实列表(工具模式必需)""" +``` + +必须实现的只有 `from_config` + `add` + `get_context` 三个。其余能力按需追加,全部有合理默认: + +| 想支持的能力 | 覆盖的方法 | 备注 | +| --- | --- | --- | +| 工具模式(`memory_search` 等) | `search()`,并设 `supports_search = True` | 声明与实现的一致性在实例化时强制校验 | +| 记忆的查看 / 清空 / 导入 | `get_memory()` / `clear_memory()` / `import_memory()` | 不实现则调用时报“不支持” | +| 事实级增删改 | `create_fact()` / `update_fact()` / `delete_fact()` | 工具模式的 `memory_add` / `memory_update` / `memory_delete` 走这里 | +| 启动预热(建索引、加载编码器等) | `warm()`,返回 `True` / `False` / `None` | `None` 表示无事可做,日志会如实记录“跳过” | +| 释放连接等外部资源 | `close()` | 优雅停机时调用 | +| 缓存重载 | `reload_memory()` | 供带缓存的后端在带外修改后刷新 | + +### 第 3 步:声明失败与冲突语义 + +- 读取失败的处理策略通过 `read_failures_are_fatal_for_config()` 声明:默认宽容(失败返回空文本),配置 `backend_config.failure_policy.read: fail_closed` 则让调用方中止而不是降级。 +- 写冲突请抛 `MemoryConflictError`(Gateway 映射为 HTTP 409),存储损坏抛 `MemoryCorruptionError`(HTTP 500),不要靠异常文本匹配。 + +### 第 4 步:启用 + +```yaml +memory: + manager_class: mybackend # 即 backends/<目录名> +``` + +也可以不放进 `backends/` 目录,`manager_class` 直接填点号路径:`mypackage.mymodule:MyMemoryManager`。无论哪种方式,解析失败都会在启动时报 `ValueError`,不会静默回退。 + +### 可用的宿主钩子 + +`from_config` 收到的 `**host_hooks` 里带有一组宿主默认能力,按需取用: + +| 钩子 | 作用 | +| --- | --- | +| `callbacks` | `MemoryCallbacks` 实例:在你的 LLM 调用前后触发,默认实现把记忆提取作为独立 span 呈现在 Langfuse | +| `host_llm_factory` | 宿主默认模型工厂:你没有配置专属提取模型时,用它做零配置提取 | +| `should_keep_hidden_message` | 隐藏消息过滤:默认只保留带人工澄清回复的消息进入记忆 | +| `trace_context_manager` | 追踪上下文管理器 | +| `extraction_callback` | 提取指标回调(token 用量、置信度过滤、拒绝率等) | + +不消费任何钩子也完全合法(`noop` 后端就什么都不用)。 + +### 测试约定 + +后端专属测试遵循命名约定 `backend/tests/test__memory_backend.py`,已有 deermem / mem0 / honcho / openviking 的测试可作参考模板。 + ## 配置 ```yaml @@ -99,6 +264,8 @@ DeerFlow 支持两级记忆: ## 自定义存储后端 +> 这一节说的是**替换 DeerMem 内部的存储层**(更轻量,只换“存哪”)。如果想接入一个完全独立的记忆系统,请看上面的[「实现自己的记忆后端」](#实现自己的记忆后端)。 + `storage_class` 字段允许你用自定义实现替换默认的基于文件的存储。任何继承 `MemoryStorage`(位于 `deerflow.agents.memory.backends.deermem.deermem.core.storage`)并实现 `load()`、`reload()` 和 `save()` 方法的类都可以使用: ```yaml