mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-29 09:26:00 +00:00
feat(memory): keep tool-mode fact recall explicit (#4521)
* feat(memory): keep tool-mode fact recall explicit * fix(memory): clarify optional tool-mode context
This commit is contained in:
parent
e41f4c9402
commit
b3af8c9183
@ -983,6 +983,8 @@ Memory updates now skip duplicate fact entries at apply time, so repeated prefer
|
||||
|
||||
File-backed memory now separates global user context from agent facts. Each user has one `memory.json` containing only the project-independent `user` and `history` summaries; every fact is a canonical Markdown file below `agents/{agent_name}/facts/`. Existing lead-agent middleware, API, Settings, import/export, and embedded-client calls that omit `agent_name` resolve inside DeerMem to the reserved `__default__` bucket. That bucket is outside the valid custom-agent name grammar, so a real custom agent named `lead-agent` has a separate fact repository and deleting a custom agent cannot delete a memory-only directory without `config.yaml`. Public agent identifiers are case-insensitive and canonicalized to lowercase. Runtime/API readers still receive a compatibility `facts` array for the selected/default agent, so the frontend does not read agent facts from `memory.json`; structured Markdown `source` metadata is projected to the historical string field at the MemoryManager boundary. An unscoped Clear All first migrates facts from unread legacy per-agent JSON without adopting its soon-to-be-cleared summaries, then removes shared summaries and facts from every agent bucket while preserving agent configuration files, so a later read cannot resurrect skipped legacy facts; an explicitly agent-scoped clear removes only that agent's facts. On first normal read, old facts embedded in the user JSON are migrated automatically to `__default__`; facts written to the earlier implicit `lead-agent` bucket are also moved when that directory is not a real custom agent. Migration and normal writes notify the configured retrieval adapter only after durable storage locks are released. DeerMem uses a scope-aware SQLite FTS5/BM25 adapter by default, stores only rebuildable derived index data under `.retrieval/`, and rebuilds it in the background during Gateway startup or lazily on the first scoped search. A corrupt derived index is recreated automatically. Set `memory.backend_config.retrieval_adapter` to an empty string to disable it and use the local substring fallback. Chinese tokenization is optional; install the backend `memory-zh` extra (`uv sync --extra memory-zh`) for jieba-assisted sub-phrase search. Journaled writes, a shared user lock, and optimistic user-memory revisions prevent silent lost updates.
|
||||
|
||||
Memory injection follows the configured operation mode. In `middleware` mode, DeerMem injects the user-global summaries and the selected agent's facts. 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.
|
||||
|
||||
Single-fact repository operations are genuinely incremental: an upsert/delete reads, journals, writes, and re-indexes only the addressed fact files, and returns an explicit incomplete delta rather than a cache-dependent fake full document. Summary change sets merge the supplied `user`/`history` child keys over the persisted sections so a partial update cannot erase omitted siblings; full imports normalize both sections to the complete compatibility schema before applying replacement values. Manager/API compatibility methods materialize a fresh full document only when their public response contract requires one. Fact-level point operations use separate expected user-memory and fact revisions and may explicitly rebase when every addressed fact precondition still holds. Snapshot-derived operations such as scoped clear, capped create, consolidation, and trimming never replay stale delete/trim sets: a manifest conflict reloads the complete document and recomputes the operation, with a bounded retry. Fact paths use the first two hexadecimal characters of `SHA-256(fact_id)` so generated `fact_*` IDs distribute across shards. The cache token combines the shared JSON's nanosecond mtime, size, and persisted revision; this prevents coarse-mtime same-size writes from returning stale data without scanning fact files. Direct out-of-band Markdown edits require an explicit reload. Storage-specific conflicts and corruption are translated at the MemoryManager boundary; the Gateway returns conflict as HTTP 409 and a stable, non-sensitive corruption error as HTTP 500. Full-document `save()` remains a compatibility API and computes a diff before writing; malformed or missing `facts` can no longer silently erase an agent's Markdown files. Legacy migration preserves non-empty `user`/`history` before deleting an agent `memory.json`; conflicting summaries keep the legacy file and fail loudly instead of choosing a winner.
|
||||
|
||||
Legacy facts in `memory.json` migrate automatically into the reserved `__default__` Markdown bucket on the user's first normal memory read. Operators who prefer to audit or complete the migration before serving traffic can run the optional idempotent CLI from `backend/`:
|
||||
|
||||
@ -867,7 +867,7 @@ The cached value is reused for both the blocking (`runs.wait`) and streaming (`_
|
||||
client operations within its timeout. It does not implement DeerMem fact
|
||||
CRUD/import/export and must not import the OpenViking embedded runtime.
|
||||
- `memory.mode: tool` skips `MemoryMiddleware` and registers `memory_search`, `memory_add`, `memory_update`, and `memory_delete` on the agent. The model decides when to search, add, update, or delete facts; this is opt-in/experimental and should not be described as better than middleware mode without eval evidence.
|
||||
- Both modes share `FileMemoryStorage`, per-user/per-agent isolation, prompt injection, manual CRUD primitives, and the updater backend.
|
||||
- Both modes share `FileMemoryStorage`, per-user/per-agent isolation, manual CRUD primitives, and the updater backend. Injection is mode-aware: middleware mode injects global `user`/`history` summaries plus the selected agent's facts, while tool mode injects only the global summaries and leaves every agent fact behind `memory_search` to avoid duplicating automatically injected and retrieval-returned context. `memory.injection_enabled: false` suppresses the complete block in either mode.
|
||||
- Middleware mode queue debounces (30s default), batches updates, and commits global summaries plus the selected/default agent's fact delta through a user-level lock, optimistic user-memory revisions, per-fact revisions, and a recoverable target-file journal. Only explicitly marked point operations may rebase a stale shared revision, and only while every addressed fact still satisfies its original absent/revision precondition. Snapshot-derived clear/trim/consolidation operations instead reload the complete document and recompute their intent on a manifest conflict, with a bounded retry. Typed manifest/fact conflict subclasses keep that decision independent of exception text, and same-ID creates and stale same-fact writes fail. Scope-lock objects are weakly cached so inactive users do not grow a process-lifetime map. Cache validation does not scale with the fact-file count: its token combines the shared JSON's `(mtime_ns, size, revision)`, so the persisted revision invalidates stale caches even when a coarse-mtime filesystem reports identical metadata for same-size writes; direct out-of-band Markdown edits require `reload()`. Atomic replacement also syncs the parent directory on POSIX so the rename is durable. DeerMem translates private storage conflict/corruption exceptions to the backend-neutral MemoryManager contract; the Gateway maps them to HTTP 409 and a stable HTTP 500 response respectively. A normal default-manager read automatically migrates legacy facts from the global JSON into `__default__`; it also adopts the earlier implicit `lead-agent` fact bucket only when that directory has no custom-agent `config.yaml`, and rejects unexpected files instead of deleting them. The v1-to-v2 migration is one-way for the running application: operators must stop DeerFlow and snapshot the configured storage root before upgrade. Before any destructive v2 write, every migrated JSON source is durably retained as `{manifest_filename}.v1.bak`; a missing-write or mismatched existing backup aborts without modifying v1 data. Legacy per-agent JSON is deleted only after its non-empty summaries are safely adopted or confirmed identical; summary conflicts keep the source file and fail loudly.
|
||||
- **Proactive Markdown migration CLI**: from `backend/`, run `PYTHONPATH=. python scripts/migrate_memory_markdown.py --all-users --dry-run` to audit and omit `--dry-run` to migrate before serving traffic. Use repeated `--user-id` values when selecting exact original identities, especially standalone raw IDs containing `@` or other characters that are normalized in directory names; `--storage-path` selects a non-default DeerMem root. The CLI reuses `FileMemoryStorage.migrate`, is idempotent, continues across per-user failures, and exits non-zero if any user fails. It is optional because the first normal read still performs the same migration automatically.
|
||||
- `retrieval_adapter` owns indexing and retrieval. `fts5` is the DeerMem default and uses a persistent derived SQLite index under `.retrieval/`; an empty value disables the adapter and selects `substring_fallback`. File storage sends upsert/remove notifications for normal writes and both explicit and lazy migrations after releasing durable storage locks, then delegates search. Gateway startup schedules `DeerMem.warm_retrieval()` as a background full rebuild so readiness is not delayed, while a first search lazily rebuilds its exact scope until warm-up completes. Individual malformed facts are logged and skipped without triggering repeated full scans; only a fatal adapter rebuild failure keeps lazy retry enabled. During shutdown, the Gateway waits at most one second for this derived rebuild and leaves the full configured timeout to the canonical memory flush; if the rebuild is still active, its adapter remains open until process exit. Adapter failures mark the scope dirty and fall back to canonical substring search until rebuilding succeeds. `FileMemoryStorage` owns and closes the adapter so higher layers do not reach into private storage state.
|
||||
|
||||
@ -1001,8 +1001,8 @@ def _build_memory_tool_section(*, app_config: AppConfig | None = None) -> str:
|
||||
return ""
|
||||
|
||||
return """<memory_tool_system>
|
||||
Memory is running in tool mode. Use the injected <memory> block as current context, and use the memory tools to keep durable user memory accurate:
|
||||
- Call `memory_search` before relying on memory that may be absent, stale, or too broad for the injected context.
|
||||
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_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.
|
||||
|
||||
@ -302,12 +302,18 @@ class DeerMem(MemoryManager):
|
||||
) -> str:
|
||||
"""Load memory and format it for injection (plain text, no wrap).
|
||||
|
||||
Middleware mode injects the selected agent's facts together with the
|
||||
user-global summaries. Tool mode injects only those global summaries;
|
||||
facts stay behind ``memory_search`` so they are not duplicated in the
|
||||
prompt and a later retrieval result.
|
||||
|
||||
Format parameters come from DeerMem's own ``DeerMemConfig`` (set at
|
||||
construction from ``backend_config``). The ``enabled``/
|
||||
``injection_enabled`` gate and the ``<memory>`` wrapping stay at the
|
||||
call site (``_get_memory_context``); this returns only the body.
|
||||
"""
|
||||
memory_data = _call_backend(lambda: self._updater.get_memory_data(agent_name=_resolve_agent_name(agent_name), user_id=user_id))
|
||||
injection_agent = None if self.mode == "tool" else _resolve_agent_name(agent_name)
|
||||
memory_data = _call_backend(lambda: self._updater.get_memory_data(agent_name=injection_agent, user_id=user_id))
|
||||
return format_memory_for_injection(
|
||||
memory_data,
|
||||
max_tokens=self._config.max_injection_tokens,
|
||||
|
||||
@ -133,6 +133,46 @@ def test_zero_config_defaults_run_non_llm_ops(deermem_data_dir):
|
||||
assert dm.get_memory(user_id="u")["facts"][0]["content"] == "x"
|
||||
|
||||
|
||||
def test_get_context_injects_facts_only_in_middleware_mode(deermem_data_dir):
|
||||
backend_config = {
|
||||
"storage_path": str(deermem_data_dir),
|
||||
"retrieval_adapter": "",
|
||||
"token_counting": "char",
|
||||
}
|
||||
middleware = DeerMem(backend_config=backend_config, mode="middleware")
|
||||
middleware.import_memory(
|
||||
{
|
||||
"user": {
|
||||
"workContext": {"summary": "Works on DeerFlow memory."},
|
||||
},
|
||||
"history": {
|
||||
"recentMonths": {"summary": "Recently redesigned storage."},
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"id": "fact_tool_only",
|
||||
"content": "Use FTS5 for active fact recall.",
|
||||
"category": "constraint",
|
||||
"confidence": 0.9,
|
||||
"source": "manual",
|
||||
}
|
||||
],
|
||||
},
|
||||
user_id="u",
|
||||
)
|
||||
|
||||
middleware_context = middleware.get_context(user_id="u")
|
||||
tool_context = DeerMem(backend_config=backend_config, mode="tool").get_context(user_id="u")
|
||||
|
||||
assert "Works on DeerFlow memory." in middleware_context
|
||||
assert "Recently redesigned storage." in middleware_context
|
||||
assert "Use FTS5 for active fact recall." in middleware_context
|
||||
assert "Works on DeerFlow memory." in tool_context
|
||||
assert "Recently redesigned storage." in tool_context
|
||||
assert "Use FTS5 for active fact recall." not in tool_context
|
||||
assert "Facts:" not in tool_context
|
||||
|
||||
|
||||
def test_import_without_agent_name_persists_facts_in_default_markdown_bucket(deermem_data_dir):
|
||||
dm = DeerMem(backend_config=None)
|
||||
dm.import_memory(
|
||||
|
||||
@ -110,7 +110,7 @@ def test_apply_prompt_template_includes_memory_tool_guidance_only_in_tool_mode(m
|
||||
skills=SimpleNamespace(container_path="/mnt/skills", use="deerflow.skills.storage.local_skill_storage:LocalSkillStorage", get_skills_path=lambda: Path("/tmp/skills")),
|
||||
skill_evolution=SimpleNamespace(enabled=False),
|
||||
tool_search=SimpleNamespace(enabled=False),
|
||||
memory=SimpleNamespace(enabled=True, mode="tool"),
|
||||
memory=SimpleNamespace(enabled=True, mode="tool", injection_enabled=False),
|
||||
acp_agents={},
|
||||
)
|
||||
middleware_config = SimpleNamespace(
|
||||
@ -133,6 +133,8 @@ def test_apply_prompt_template_includes_memory_tool_guidance_only_in_tool_mode(m
|
||||
assert "<memory_tool_system>" in tool_prompt
|
||||
assert "memory_search" in tool_prompt
|
||||
assert "memory_add" in tool_prompt
|
||||
assert "agent facts are not injected automatically" in tool_prompt
|
||||
assert "When present, the injected <memory> block contains only global user and history summaries" in tool_prompt
|
||||
assert "<memory_tool_system>" not in middleware_prompt
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user