feat(memory): add scoped fact lookup by ID (#6443)

* feat(memory): add scoped fact lookup by ID

* docs(memory): synchronize tool-mode lookup guidance

* docs(memory): clarify named-agent read capability requirement
This commit is contained in:
ZJPex 2026-10-08 09:25:12 +08:00 committed by GitHub
parent d94246ad92
commit 220d62d79a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 133 additions and 15 deletions

View File

@ -2309,7 +2309,7 @@ Search and automatic injection use the same IDF weighting for the same
user/agent candidate scope. Category-filtered search and injection's separate
guaranteed/regular token budgets can still yield different final selections.
Memory injection follows the configured operation mode. In `middleware` mode, DeerMem injects the user-global summaries and the selected agent's facts. Custom-agent bootstrap conversations use that agent's fact bucket as well, so setup details do not leak into the default agent's memory. In `tool` mode, the automatic `<memory>` block contains only the global `user` and `history` summaries; agent facts are retrieved explicitly through `memory_search`, avoiding duplicate automatic and tool-returned fact context. Setting `memory.injection_enabled: false` still disables the entire block in either mode.
Memory injection follows the configured operation mode. In `middleware` mode, DeerMem injects the user-global summaries and the selected agent's facts. Custom-agent bootstrap conversations use that agent's fact bucket as well, so setup details do not leak into the default agent's memory. In `tool` mode, the automatic `<memory>` block contains only the global `user` and `history` summaries; agent facts are retrieved explicitly through `memory_search`, avoiding duplicate automatic and tool-returned fact context. Setting `memory.injection_enabled: false` still disables the entire block in either mode. When a fact ID is already known, `memory_get(fact_id)` returns that exact fact from the current user and agent scope without searching by content. Missing or out-of-scope IDs return a not-found error; backends without the required read or agent-scope capability return an unsupported error.
An individual Custom Agent can opt out of memory without changing the global setting. Add `memory_enabled: false` to that agent's `users/{user_id}/agents/{name}/config.yaml`. The agent still receives the current-date reminder, but DeerFlow does not inject recalled memory, queue passive or summarization-driven memory updates (including manual `/compact`), expose memory tools, or add memory-tool instructions for that agent. If an existing agent is switched off, its previously injected memory block is removed from checkpoint state before the next model call while its date reminder and conversation remain. Omitting the field (or setting it to `true`) preserves the existing global `memory` behavior.

View File

@ -1018,6 +1018,7 @@ def _build_memory_tool_section(*, app_config: AppConfig | None = None, memory_en
return """<memory_tool_system>
Memory is running in tool mode. When present, the injected <memory> block contains only global user and history summaries; agent facts are not injected automatically. Use the memory tools to keep durable user memory accurate:
- Call `memory_search` whenever prior preferences, constraints, corrections, or durable context may be relevant. Do not assume an absent fact does not exist until you have searched with an appropriate query.
- Call `memory_get` to read a specific fact when you already know its ID from a memory search or addition.
- Call `memory_add` only for stable facts useful in future sessions: explicit user preferences, corrections, personal/work context, or durable project context.
- Call `memory_update` when an existing fact is outdated or imprecise; prefer updating over adding a near-duplicate.
- Call `memory_delete` only when a fact is clearly wrong or no longer relevant.

View File

@ -13,7 +13,7 @@ This directory owns memory capture, storage, retrieval, prompt injection, and mo
- `manager.py` defines the backend-neutral `MemoryManager` contract.
- `agents/middlewares/memory_middleware.py` queues filtered conversations for passive capture.
- `summarization_hook.py` connects memory work to the summarization lifecycle.
- `tools.py` provides `memory_search`, `memory_add`, `memory_update`, and `memory_delete`.
- `tools.py` provides `memory_search`, `memory_get`, `memory_add`, `memory_update`, and `memory_delete`.
- `backends/deermem/` contains the default local backend.
- `backends/mem0/`, `backends/openviking/`, and `backends/honcho/` contain optional adapters.
@ -97,13 +97,17 @@ Memory enqueue redaction also covers `invalid_tool_calls` arguments/error text
and legacy `function_call` payloads. Keep sync, async and compaction admission
aligned; preserve original messages and detector policy.
`memory.mode: tool` registers the four memory tools.
`memory.mode: tool` registers the five memory tools.
The model chooses when to search or change facts.
Tool mode still uses `MemoryMiddleware` for passive writes on supported remote backends.
Middleware injection includes shared summaries and the selected agent's facts.
Tool-mode injection includes only shared summaries.
Tool mode leaves agent facts behind `memory_search`.
Tool mode leaves agent facts behind `memory_search` and `memory_get`.
`memory_get` matches an exact fact ID inside `MemoryManager.get_memory()` for
the runtime user and agent. Named-agent reads require
`supports_agent_scoped_management`; unsupported reads return JSON errors.
Missing and out-of-scope IDs share the same not-found response.
`memory.injection_enabled: false` disables the complete injected block.
Per-user lead-agent Custom Agents may set `memory_enabled: false` in their own

View File

@ -1,6 +1,6 @@
"""Memory tools for tool-driven memory mode.
Exposes memory_search, memory_add, memory_update, memory_delete as
Exposes memory_search, memory_get, memory_add, memory_update, memory_delete as
LangChain @tool functions the model can call directly.
When memory.mode == "tool", these tools are registered on the agent. Most
@ -84,6 +84,38 @@ def memory_search_tool(
return json.dumps({"error": str(exc)})
@tool("memory_get", parse_docstring=True)
def memory_get_tool(runtime: Runtime, fact_id: str) -> str:
"""Read one stored fact by its exact ID in the current user and agent scope.
Use this to revisit a fact whose ID you already know from memory_search
or memory_add. Use memory_search when you do not know the ID.
Args:
fact_id: Exact fact ID returned by memory_search or memory_add.
Returns:
JSON string with "fact" containing the stored fact object.
Missing facts and unsupported backends return "error".
"""
agent_name, user_id = _resolve_scope(runtime)
try:
manager = get_memory_manager()
if agent_name is not None and getattr(manager, "supports_agent_scoped_management", False) is not True:
return json.dumps({"error": f"memory backend {type(manager).__name__} does not support agent-scoped memory reads"})
try:
memory = manager.get_memory(user_id=user_id, agent_name=agent_name)
except NotImplementedError:
return json.dumps({"error": f"memory backend {type(manager).__name__} does not support get_memory"})
for fact in memory.get("facts", []):
if fact.get("id") == fact_id:
return json.dumps({"fact": fact}, ensure_ascii=False)
return json.dumps({"error": f"Fact not found: {fact_id}"})
except Exception as exc:
logger.exception("memory_get_tool failed")
return json.dumps({"error": str(exc)})
@tool("memory_add", parse_docstring=True)
def memory_add_tool(
runtime: Runtime,
@ -244,6 +276,7 @@ def get_memory_tools() -> list:
"""
return [
memory_search_tool,
memory_get_tool,
memory_add_tool,
memory_update_tool,
memory_delete_tool,

View File

@ -0,0 +1,74 @@
"""Exact fact recall through the tool-mode memory boundary."""
import json
from types import SimpleNamespace
import pytest
from deerflow.agents.memory import tools
from deerflow.agents.memory.backends.deermem.deer_mem import DeerMem
from deerflow.agents.memory.backends.noop.noop_manager import NoopMemoryManager
from deerflow.agents.memory.manager import MemoryManager
def _get(runtime, fact_id):
return json.loads(tools.memory_get_tool.func(runtime, fact_id))
def test_get_is_registered_with_runtime_hidden():
registered = {tool.name: tool for tool in tools.get_memory_tools()}
assert "memory_get" in registered
assert set(registered["memory_get"].tool_call_schema.model_fields) == {"fact_id"}
@pytest.mark.parametrize("agent_name", [None, "research"])
def test_get_reads_only_the_runtime_user_and_agent(tmp_path, monkeypatch, agent_name):
monkeypatch.setenv("DEERMEM_DATA_DIR", str(tmp_path))
manager = DeerMem(backend_config={"retrieval_adapter": ""})
monkeypatch.setattr(tools, "get_memory_manager", lambda: manager)
document, fact_id = manager.create_fact("Prefers Python", user_id="alice", agent_name=agent_name)
fact = next(fact for fact in document["facts"] if fact["id"] == fact_id)
runtime = SimpleNamespace(server_info=SimpleNamespace(user=SimpleNamespace(identity="alice")), context={"user_id": "bob", "agent_name": agent_name})
before = {path: path.read_bytes() for path in tmp_path.rglob("*") if path.is_file()}
assert _get(runtime, fact_id) == {"fact": fact}
assert _get(runtime, "missing") == {"error": "Fact not found: missing"}
for context in (
{"user_id": "bob", "agent_name": agent_name},
{"user_id": "alice", "agent_name": "other-agent"},
):
assert _get(SimpleNamespace(context=context), fact_id) == {"error": f"Fact not found: {fact_id}"}
assert all(path.read_bytes() == content for path, content in before.items())
def test_get_matches_id_exactly_and_preserves_unicode(monkeypatch):
fact = {"id": "fact_1", "content": "喜欢 Python", "category": "preference", "confidence": 0.9}
manager = SimpleNamespace(get_memory=lambda **kwargs: {"facts": [{"id": "fact_10", "content": "other"}, fact]})
monkeypatch.setattr(tools, "get_memory_manager", lambda: manager)
assert _get(SimpleNamespace(context={}), "fact_1") == {"fact": fact}
assert _get(SimpleNamespace(context={}), "fact_") == {"error": "Fact not found: fact_"}
def test_get_unsupported_backend_returns_error(monkeypatch):
class UnsupportedReadBackend(NoopMemoryManager):
get_memory = MemoryManager.get_memory
monkeypatch.setattr(tools, "get_memory_manager", lambda: UnsupportedReadBackend())
assert _get(SimpleNamespace(context={}), "fact_1") == {"error": "memory backend UnsupportedReadBackend does not support get_memory"}
def test_get_rejects_unsupported_agent_scope_before_reading(monkeypatch):
def unexpected_read(**kwargs):
pytest.fail("An unscoped backend must not read a named agent's facts")
monkeypatch.setattr(tools, "get_memory_manager", lambda: SimpleNamespace(get_memory=unexpected_read))
result = _get(SimpleNamespace(context={"agent_name": "research"}), "fact_1")
assert "does not support agent-scoped memory reads" in result["error"]
def test_get_backend_failure_returns_error(monkeypatch):
def failed_read(**kwargs):
raise RuntimeError("read failed")
monkeypatch.setattr(tools, "get_memory_manager", lambda: SimpleNamespace(get_memory=failed_read))
assert _get(SimpleNamespace(context={}), "fact_1") == {"error": "read failed"}

View File

@ -121,10 +121,10 @@ def _install_manager(monkeypatch, manager):
class TestGetMemoryTools:
"""Tests for get_memory_tools registry."""
def test_returns_four_tools(self):
"""Should return exactly 4 tools."""
def test_returns_five_tools(self):
"""Should return exactly 5 tools."""
tools = get_memory_tools()
assert len(tools) == 4
assert len(tools) == 5
def test_tools_have_unique_names(self):
"""All tools should have unique names."""
@ -132,6 +132,7 @@ class TestGetMemoryTools:
names = [t.name for t in tools]
assert len(names) == len(set(names))
assert "memory_search" in names
assert "memory_get" in names
assert "memory_add" in names
assert "memory_update" in names
assert "memory_delete" in names
@ -392,6 +393,7 @@ class TestModeGating:
tool_names = [t.name for t in extra_tools]
assert "memory_search" in tool_names
assert "memory_get" in tool_names
assert "memory_add" in tool_names
assert "memory_update" in tool_names
assert "memory_delete" in tool_names

View File

@ -2236,7 +2236,7 @@ memory:
manager_class: deermem
# Memory operation mode:
# middleware (default) - passive background extraction after each turn.
# tool - experimental opt-in; the model calls memory_search/memory_add/
# tool - experimental opt-in; the model calls memory_search/memory_get/memory_add/
# memory_update/memory_delete directly. This gives the model agency over
# memory writes, but effectiveness depends on model tool-use behavior.
# Normally only one mode runs at a time. A backend that needs conversation-

View File

@ -33,10 +33,12 @@ Memory has two operation modes:
1. **Injection in both modes**: at the start of each conversation, the agent's current memory is injected into the system prompt at a controlled token budget (`max_injection_tokens`).
2. **Middleware mode (default)**: `MemoryMiddleware` runs after each Lead Agent turn. It filters the conversation, queues a background update, and extracts new facts automatically. Updates are debounced by `debounce_seconds` to batch rapid changes.
3. **Tool mode (experimental)**: DeerFlow registers `memory_search`, `memory_add`, `memory_update`, and `memory_delete` as agent tools and skips `MemoryMiddleware`. The model decides when to search or write memory, so effectiveness depends on model tool-use behavior. These explicit CRUD tools are not the passive staleness-review path; operators who enable tool mode are opting into model-directed updates/deletes instead of the middleware staleness guardrails.
3. **Tool mode (experimental)**: DeerFlow registers `memory_search`, `memory_get`, `memory_add`, `memory_update`, and `memory_delete` as agent tools and skips `MemoryMiddleware`. The model decides when to search, read, or write memory, so effectiveness depends on model tool-use behavior. These explicit CRUD tools are not the passive staleness-review path; operators who enable tool mode are opting into model-directed updates/deletes instead of the middleware staleness guardrails.
4. **Per-agent memory**: when a custom agent is active, its memory is stored separately from the global memory. This keeps different agents' knowledge isolated.
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.
In tool mode, the five `memory_*` tool names are reserved. If an MCP server or custom tool already uses `memory_search`, `memory_get`, `memory_add`, `memory_update`, or `memory_delete`, the existing tool keeps that name and the colliding memory tool is skipped with a warning.
`memory_get(fact_id)` reads a stored fact by its exact ID in the current runtime user and agent scope. Use `memory_search` when the ID is unknown. Missing and out-of-scope IDs return the same not-found error; unsupported reads return an error. Named-agent reads also return an error when the backend does not support agent-scoped management.
## Memory Manager: the pluggable memory manager

View File

@ -30,7 +30,7 @@ By default, memory works automatically through `MemoryMiddleware`:
2. **Automatic learning**: the agent extracts and saves important facts in the background.
3. **Future conversations**: memory facts are automatically injected into the system prompt — the agent does not need you to repeat context.
DeerFlow also has an experimental `mode: tool` option. In tool mode, the agent receives `memory_search`, `memory_add`, `memory_update`, and `memory_delete` tools and decides when to call them. Keep `mode: middleware` unless you specifically want to evaluate model-driven memory behavior.
DeerFlow also has an experimental `mode: tool` option. In tool mode, the agent receives `memory_search`, `memory_get`, `memory_add`, `memory_update`, and `memory_delete` tools and decides when to call them. Use `memory_get(fact_id)` to reread a fact whose ID is already known, or `memory_search` when the ID is unknown. Keep `mode: middleware` unless you specifically want to evaluate model-driven memory behavior.
## Example

View File

@ -32,10 +32,12 @@ import { Callout } from "nextra/components";
1. **两种模式都会注入**:每次对话开始时,Agent 当前记忆以受控的 token 预算(`max_injection_tokens`)注入到系统提示中。
2. **中间件模式(默认)**:`MemoryMiddleware` 在每次 Lead Agent 轮次结束后运行,过滤对话、排队后台更新并自动提取新事实。更新通过 `debounce_seconds` 防抖以批量处理快速变化。
3. **工具模式(实验性)**:DeerFlow 注册 `memory_search`、`memory_add`、`memory_update`、`memory_delete` 四个工具,并跳过 `MemoryMiddleware`。模型自行决定何时搜索或写入记忆,因此效果取决于模型的工具调用行为。这些显式 CRUD 工具不是被动的陈旧性复核路径;启用工具模式意味着运营方选择由模型主动更新/删除记忆,而不是依赖中间件的陈旧性保护栏。
3. **工具模式(实验性)**:DeerFlow 注册 `memory_search`、`memory_get`、`memory_add`、`memory_update`、`memory_delete` 五个工具,并跳过 `MemoryMiddleware`。模型自行决定何时搜索、读取或写入记忆,因此效果取决于模型的工具调用行为。这些显式 CRUD 工具不是被动的陈旧性复核路径;启用工具模式意味着运营方选择由模型主动更新/删除记忆,而不是依赖中间件的陈旧性保护栏。
4. **按 Agent 记忆**:当自定义 Agent 激活时,其记忆独立于全局记忆存储。这保持不同 Agent 的知识隔离。
在工具模式中,四个 `memory_*` 工具名是保留名。如果 MCP 服务或自定义工具已经使用 `memory_search`、`memory_add`、`memory_update` 或 `memory_delete`,已有工具会保留该名称,冲突的记忆工具会被跳过并记录告警。
在工具模式中,五个 `memory_*` 工具名是保留名。如果 MCP 服务或自定义工具已经使用 `memory_search`、`memory_get`、`memory_add`、`memory_update` 或 `memory_delete`,已有工具会保留该名称,冲突的记忆工具会被跳过并记录告警。
`memory_get(fact_id)` 按精确 ID 读取当前运行时用户和 Agent 范围内的已存事实。ID 未知时使用 `memory_search`。不存在和超出范围的 ID 返回相同的未找到错误;后端不支持读取或按名称指定的 Agent 范围时返回错误。
## Memory Manager:可插拔的记忆管理器

View File

@ -28,7 +28,7 @@ memory:
2. **自动学习**:Agent 在后台提取并保存重要事实。
3. **后续对话**:记忆事实自动注入到系统提示中,Agent 无需你重新解释背景。
DeerFlow 也提供实验性的 `mode: tool`。在工具模式中,Agent 会获得 `memory_search`、`memory_add`、`memory_update` 和 `memory_delete` 工具,并自行决定何时调用。除非你明确想评估模型主动管理记忆的行为,否则建议保持 `mode: middleware`。
DeerFlow 也提供实验性的 `mode: tool`。在工具模式中,Agent 会获得 `memory_search`、`memory_get`、`memory_add`、`memory_update` 和 `memory_delete` 工具,并自行决定何时调用。已知事实 ID 时,使用 `memory_get(fact_id)` 重新读取;ID 未知时使用 `memory_search`。除非你明确想评估模型主动管理记忆的行为,否则建议保持 `mode: middleware`。
## 示例