feat(memory): add memory tool sets (#4023)

* feat: add memory-as-tool mode alongside existing middleware mode

- Add memory.mode config field (middleware|tool, default middleware)
- Add search_memory_facts() for case-insensitive fact lookup
- Add 4 memory tools: memory_search, memory_add, memory_update, memory_delete
- Wire mode gating in factory.py and lead_agent/agent.py
- 256 memory tests passing, zero regressions

* fix: harden tool-mode memory scoping and docs

* fix: address memory tool mode review feedback

* fix(memory): address tool mode review feedback

* fix: update config_version to 22 in values.yaml
This commit is contained in:
Vanzeren 2026-07-11 15:28:16 +08:00 committed by GitHub
parent bbb3deb231
commit c2002d9fac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1185 additions and 48 deletions

View File

@ -553,12 +553,13 @@ The cached value is reused for both the blocking (`runs.wait`) and streaming (`_
- `queue.py` - Debounced update queue (per-thread deduplication, configurable wait time); captures `user_id` at enqueue time so it survives the `threading.Timer` boundary
- `prompt.py` - Prompt templates for memory updates
- `storage.py` - File-based storage with per-user isolation; cache keyed by `(user_id, agent_name)` tuple
- `tools.py` - Tool-driven memory mode (`memory_search`, `memory_add`, `memory_update`, `memory_delete`) using the same storage/update primitives
**Per-User Isolation**:
- Memory is stored per-user at `{base_dir}/users/{user_id}/memory.json`
- Per-agent per-user memory at `{base_dir}/users/{user_id}/agents/{agent_name}/memory.json`
- Custom agent definitions (`SOUL.md` + `config.yaml`) are also per-user at `{base_dir}/users/{user_id}/agents/{agent_name}/`. The legacy shared layout `{base_dir}/agents/{agent_name}/` remains read-only fallback for unmigrated installations
- `user_id` is resolved via `get_effective_user_id()` from `deerflow.runtime.user_context`
- Middleware mode captures `user_id` via `get_effective_user_id()` at enqueue time; tool mode resolves `user_id` and `agent_name` from `ToolRuntime.context` via `resolve_runtime_user_id(runtime)` so tool calls stay scoped to the authenticated user and active custom agent
- The `/api/memory*` endpoints resolve the owner through `_resolve_memory_user_id(request)`: trusted internal callers (IM channel workers carrying the `X-DeerFlow-Owner-User-Id` header, e.g. a bound `/memory` command) act for the connection owner; browser/API callers fall back to `get_effective_user_id()`. The header is only honored after `AuthMiddleware` validated the internal token, mirroring `get_trusted_internal_owner_user_id` used by the threads router
- In no-auth mode, `user_id` defaults to `"default"` (constant `DEFAULT_USER_ID`)
- Absolute `storage_path` in config opts out of per-user isolation
@ -570,13 +571,13 @@ The cached value is reused for both the blocking (`runs.wait`) and streaming (`_
- **Facts**: Discrete facts with `id`, `content`, `category` (preference/knowledge/context/behavior/goal), `confidence` (0-1), `createdAt`, `source`
**Workflow**:
1. `MemoryMiddleware` filters messages (user inputs + final AI responses), captures `user_id` via `get_effective_user_id()`, and queues conversation with the captured `user_id`
2. Queue debounces (30s default), batches updates, deduplicates per-thread
3. Background thread invokes LLM to extract context updates and facts, using the stored `user_id` (not the contextvar, which is unavailable on timer threads)
4. Applies updates atomically (temp file + rename) with cache invalidation, skipping duplicate fact content before append
5. **Staleness pass** (same LLM invocation as step 3, no extra API call): when `staleness_review_enabled` is `true` and at least `staleness_min_candidates` aged facts exist, `_select_stale_candidates` selects facts older than `staleness_age_days` that are not in `staleness_protected_categories` (default: `correction`), surfaces them in the prompt, and the LLM judges each as KEEP or REMOVE. `_apply_updates` enforces the guardrail unconditionally at apply time: it intersects the LLM-returned removal set with `_select_stale_candidates` output before applying the per-cycle cap (`staleness_max_removals_per_cycle`), so protected and non-aged facts can never be deleted regardless of model behavior or the feature flag setting.
5b. **Consolidation pass** (same LLM invocation as step 3, no extra API call): when `consolidation_enabled` is `true` and at least one category holds `consolidation_min_facts` or more facts, `_select_consolidation_candidates` identifies fragmented categories and surfaces at most `consolidation_max_groups_per_cycle` of them (largest first) in the prompt. The LLM decides which groups to merge and proposes a synthesised fact per group. `_apply_updates` enforces guardrails: source IDs must exist and must not overlap across groups, group size is capped at `consolidation_max_sources`, the merged fact's confidence cannot exceed the source maximum, and facts below `fact_confidence_threshold` are not written.
6. Next interaction injects top 15 facts + context into `<memory>` tags in system prompt
- `memory.mode: middleware` (default) keeps the passive path: `MemoryMiddleware` filters messages (user inputs + final AI responses), captures `user_id` via `get_effective_user_id()`, queues conversation with the captured `user_id`, and the debounced background thread invokes the LLM to extract context updates and facts using the stored `user_id`.
- `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.
- Middleware mode queue debounces (30s default), batches updates, deduplicates per-thread, applies updates atomically (temp file + rename) with cache invalidation, and skips duplicate fact content before append.
- Staleness pass (same LLM invocation as the regular updater, no extra API call): when `staleness_review_enabled` is `true` and at least `staleness_min_candidates` aged facts exist, `_select_stale_candidates` selects facts older than `staleness_age_days` that are not in `staleness_protected_categories` (default: `correction`), surfaces them in the prompt, and the LLM judges each as KEEP or REMOVE. `_apply_updates` enforces the guardrail unconditionally at apply time: it intersects the LLM-returned removal set with `_select_stale_candidates` output before applying the per-cycle cap (`staleness_max_removals_per_cycle`), so protected and non-aged facts can never be deleted regardless of model behavior or the feature flag setting.
- Consolidation pass (same LLM invocation as the regular updater, no extra API call): when `consolidation_enabled` is `true` and at least one category holds `consolidation_min_facts` or more facts, `_select_consolidation_candidates` identifies fragmented categories and surfaces at most `consolidation_max_groups_per_cycle` of them (largest first) in the prompt. The LLM decides which groups to merge and proposes a synthesised fact per group. `_apply_updates` enforces guardrails: source IDs must exist and must not overlap across groups, group size is capped at `consolidation_max_sources`, the merged fact's confidence cannot exceed the source maximum, and facts below `fact_confidence_threshold` are not written.
- Next interaction injects selected facts + context into `<memory>` tags in the system prompt when `injection_enabled` is true.
**Token counting** (`packages/harness/deerflow/agents/memory/prompt.py`):
- `_count_tokens` budgets the injection. In default `tiktoken` mode, the encoding is loaded lazily and cached.
@ -588,6 +589,7 @@ Focused regression coverage for the updater lives in `backend/tests/test_memory_
**Configuration** (`config.yaml` → `memory`):
- `enabled` / `injection_enabled` - Master switches
- `mode` - Operation mode: `middleware` (default passive background extraction) or `tool` (experimental model-driven memory tools). Modes are mutually exclusive.
- `storage_path` - Path to memory.json (absolute path opts out of per-user isolation)
- `debounce_seconds` - Wait time before processing (default: 30)
- `model_name` - LLM for updates (null = default model)

View File

@ -31,6 +31,8 @@ if TYPE_CHECKING:
from langgraph.checkpoint.base import BaseCheckpointSaver
from langgraph.graph.state import CompiledStateGraph
from deerflow.config.memory_config import MemoryConfig
logger = logging.getLogger(__name__)
@ -242,9 +244,27 @@ def _assemble_from_features(
if isinstance(feat.memory, AgentMiddleware):
chain.append(feat.memory)
else:
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.config.memory_config import get_memory_config, should_use_memory_tools
chain.append(MemoryMiddleware(agent_name=name))
memory_cfg: MemoryConfig = feat.memory_config or get_memory_config()
if should_use_memory_tools(memory_cfg):
from deerflow.agents.memory.tools import get_memory_tools
existing_names = {tool.name for tool in extra_tools}
for memory_tool in get_memory_tools():
if memory_tool.name in existing_names:
logger.warning("Memory tool name %r already exists and was skipped.", memory_tool.name)
continue
extra_tools.append(memory_tool)
existing_names.add(memory_tool.name)
# MemoryMiddleware is intentionally NOT appended in tool mode.
# The model drives memory via tools instead of passive middleware.
else:
if memory_cfg.mode == "tool" and not memory_cfg.enabled:
logger.warning("memory.mode is 'tool' but memory.enabled is false; memory tools will not be registered.")
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
chain.append(MemoryMiddleware(agent_name=name, memory_config=memory_cfg))
# --- [10] Vision ---
if feat.vision is not False:

View File

@ -6,10 +6,13 @@ Pure data classes and decorators — no I/O, no side effects.
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from typing import TYPE_CHECKING, Literal
from langchain.agents.middleware import AgentMiddleware
if TYPE_CHECKING:
from deerflow.config.memory_config import MemoryConfig
@dataclass
class RuntimeFeatures:
@ -26,6 +29,9 @@ class RuntimeFeatures:
sandbox: bool | AgentMiddleware = True
memory: bool | AgentMiddleware = False
# Explicit memory config for direct create_deerflow_agent(features=...) callers.
# The lead-agent AppConfig path passes resolved_app_config.memory directly.
memory_config: MemoryConfig | None = None
summarization: Literal[False] | AgentMiddleware = False
subagent: bool | AgentMiddleware = False
vision: bool | AgentMiddleware = False

View File

@ -41,6 +41,7 @@ from deerflow.agents.middlewares.view_image_middleware import ViewImageMiddlewar
from deerflow.agents.thread_state import ThreadState
from deerflow.config.agents_config import load_agent_config, validate_agent_name
from deerflow.config.app_config import AppConfig, get_app_config
from deerflow.config.memory_config import should_use_memory_tools
from deerflow.models import create_chat_model
from deerflow.skills.tool_policy import SKILL_LOADING_TOOL_NAMES, filter_tools_by_skill_allowed_tools
from deerflow.skills.types import Skill
@ -60,6 +61,19 @@ _NON_INTERACTIVE_DISABLED_TOOL_NAMES = frozenset({"ask_clarification"})
_WEBHOOK_CHANNELS: frozenset[str] = frozenset({"github"})
def _append_memory_tools_without_name_conflicts(tools: list) -> None:
"""Append memory tools without dropping unrelated duplicate-named tools."""
from deerflow.agents.memory.tools import get_memory_tools
existing_names = {getattr(tool, "name", None) for tool in tools}
for memory_tool in get_memory_tools():
if memory_tool.name in existing_names:
logger.warning("Memory tool name %r already exists and was skipped.", memory_tool.name)
continue
tools.append(memory_tool)
existing_names.add(memory_tool.name)
def _get_runtime_config(config: RunnableConfig) -> dict:
"""Merge legacy configurable options with LangGraph runtime context."""
cfg = dict(config.get("configurable", {}) or {})
@ -296,8 +310,13 @@ def build_middlewares(
# Add TitleMiddleware
middlewares.append(TitleMiddleware(app_config=resolved_app_config))
# Add MemoryMiddleware (after TitleMiddleware)
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory))
# Add MemoryMiddleware (after TitleMiddleware) — skipped in enabled tool mode
if should_use_memory_tools(resolved_app_config.memory):
pass
else:
if resolved_app_config.memory.mode == "tool" and not resolved_app_config.memory.enabled:
logger.warning("memory.mode is 'tool' but memory.enabled is false; memory tools will not be registered.")
middlewares.append(MemoryMiddleware(agent_name=agent_name, memory_config=resolved_app_config.memory))
# Add ViewImageMiddleware only if the current model supports vision.
# Use the resolved runtime model_name from make_lead_agent to avoid stale config values.
@ -508,6 +527,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
)
if skill_setup.describe_skill_tool:
final_tools.append(skill_setup.describe_skill_tool)
if should_use_memory_tools(resolved_app_config.memory):
_append_memory_tools_without_name_conflicts(final_tools)
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, app_config=resolved_app_config, attach_tracing=False),
tools=final_tools,
@ -571,6 +592,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
mcp_routing_hints_section = get_mcp_routing_hints_prompt_section(filtered, deferred_names=setup.deferred_names)
if skill_setup.describe_skill_tool:
final_tools.append(skill_setup.describe_skill_tool)
if should_use_memory_tools(resolved_app_config.memory):
_append_memory_tools_without_name_conflicts(final_tools)
return create_agent(
model=create_chat_model(name=model_name, thinking_enabled=thinking_enabled, reasoning_effort=reasoning_effort, app_config=resolved_app_config, attach_tracing=False),
tools=final_tools,

View File

@ -538,6 +538,8 @@ You: "Deploying to staging..." [proceed]
</clarification_system>
{skills_section}
{memory_tool_section}
{deferred_tools_section}
@ -896,6 +898,33 @@ def _build_custom_mounts_section(*, app_config: AppConfig | None = None) -> str:
return f"\n**Custom Mounted Directories:**\n{mounts_list}\n- If the user needs files outside `/mnt/user-data`, use these absolute container paths directly when they match the requested directory"
def _build_memory_tool_section(*, app_config: AppConfig | None = None) -> str:
"""Build tool-mode memory guidance for the static system prompt."""
try:
if app_config is None:
from deerflow.config.memory_config import get_memory_config
memory_config = get_memory_config()
else:
memory_config = app_config.memory
from deerflow.config.memory_config import should_use_memory_tools
if not should_use_memory_tools(memory_config):
return ""
except Exception:
logger.exception("Failed to build memory tool prompt section")
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.
- 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.
</memory_tool_system>"""
def apply_prompt_template(
subagent_enabled: bool = False,
max_concurrent_subagents: int = 3,
@ -954,6 +983,8 @@ def apply_prompt_template(
else "- Skill First: Always load the relevant skill before starting **complex** tasks.\n"
)
memory_tool_section = _build_memory_tool_section(app_config=app_config)
# Build and return the fully static system prompt.
# Memory and current date are injected per-turn via DynamicContextMiddleware
# as a <system-reminder> in the first HumanMessage, keeping this prompt
@ -966,6 +997,7 @@ def apply_prompt_template(
deferred_tools_section=deferred_tools_section,
mcp_routing_hints_section=mcp_routing_hints_section,
subagent_section=subagent_section,
memory_tool_section=memory_tool_section,
subagent_reminder=subagent_reminder,
skill_first_reminder=skill_first_reminder,
subagent_thinking=subagent_thinking,

View File

@ -23,12 +23,20 @@ from deerflow.agents.memory.storage import (
MemoryStorage,
get_memory_storage,
)
from deerflow.agents.memory.tools import (
get_memory_tools,
memory_add_tool,
memory_delete_tool,
memory_search_tool,
memory_update_tool,
)
from deerflow.agents.memory.updater import (
MemoryUpdater,
clear_memory_data,
delete_memory_fact,
get_memory_data,
reload_memory_data,
search_memory_facts,
update_memory_from_conversation,
)
@ -38,6 +46,7 @@ __all__ = [
"FACT_EXTRACTION_PROMPT",
"format_memory_for_injection",
"format_conversation_for_update",
"search_memory_facts",
# Queue
"ConversationContext",
"MemoryUpdateQueue",
@ -54,4 +63,10 @@ __all__ = [
"get_memory_data",
"reload_memory_data",
"update_memory_from_conversation",
# Tools (tool-driven mode)
"get_memory_tools",
"memory_search_tool",
"memory_add_tool",
"memory_update_tool",
"memory_delete_tool",
]

View File

@ -0,0 +1,228 @@
"""Memory tools for tool-driven memory mode.
Exposes memory_search, 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
instead of appending MemoryMiddleware. The model gains agency over
its own persistent memory: it decides what to remember, when to
search, and when to update or remove stale facts.
"""
import json
import logging
from langchain.tools import tool
from deerflow.agents.memory.updater import (
create_memory_fact_with_created_fact,
delete_memory_fact,
get_memory_data,
search_memory_facts,
update_memory_fact,
)
from deerflow.runtime.user_context import resolve_runtime_user_id
from deerflow.tools.types import Runtime
logger = logging.getLogger(__name__)
def _resolve_scope(runtime: Runtime | None = None) -> tuple[str | None, str]:
"""Resolve agent_name and user_id for tool handler scope.
Tool execution receives user and agent metadata through LangGraph runtime
context. Prefer that channel over ContextVar fallback so persistence stays
scoped correctly across request/task boundaries.
"""
context = getattr(runtime, "context", None)
agent_name = None
if isinstance(context, dict) and context.get("agent_name"):
agent_name = str(context["agent_name"])
return agent_name, resolve_runtime_user_id(runtime)
def _memory_content_key(content: str) -> str:
return content.strip().casefold()
@tool("memory_search", parse_docstring=True)
def memory_search_tool(
runtime: Runtime,
query: str,
category: str | None = None,
limit: int = 10,
) -> str:
"""Search existing facts by natural language query.
Use this when you need to check what you already know about the user
— their preferences, past corrections, context, or any stored facts.
Args:
query: Natural language query to match against fact content.
Case-insensitive substring matching.
category: Optional category filter (e.g. "preference", "correction",
"context"). Only facts with this exact category are returned.
limit: Maximum results to return (default 10).
Returns:
JSON string with "results" (list of fact objects) and "count".
Each fact has id, content, category, confidence, createdAt, and source.
"""
agent_name, user_id = _resolve_scope(runtime)
try:
results = search_memory_facts(
query,
category=category,
limit=limit,
agent_name=agent_name,
user_id=user_id,
)
return json.dumps({"results": results, "count": len(results)}, ensure_ascii=False)
except Exception as exc:
logger.exception("memory_search_tool failed")
return json.dumps({"error": str(exc)})
@tool("memory_add", parse_docstring=True)
def memory_add_tool(
runtime: Runtime,
content: str,
category: str = "context",
confidence: float = 0.7,
) -> str:
"""Store a new fact about the user or conversation context.
Use this when the user shares something worth remembering for future
conversations — preferences, corrections, personal details, work context.
The fact persists across sessions and will be available via memory_search
and automatic context injection.
Args:
content: The fact text to remember. Be specific and factual.
category: Category label for organization (default "context").
e.g. "preference", "correction", "behavior", "personal".
confidence: How certain you are about this fact, 0.0-1.0
(default 0.7). Use higher values for explicit user statements,
lower for inferences.
Returns:
JSON string with "fact_id" and "status": "added".
On duplicate content, returns "error" with explanation.
"""
agent_name, user_id = _resolve_scope(runtime)
try:
normalized_content = content.strip()
existing_key = _memory_content_key(normalized_content)
existing_facts = get_memory_data(agent_name, user_id=user_id).get("facts", [])
# Tool calls normally run one-at-a-time per user turn. If tool-mode
# writing broadens to multiple concurrent calls for the same user,
# move duplicate rejection into the storage/update critical section.
if any(_memory_content_key(str(fact.get("content", ""))) == existing_key for fact in existing_facts):
return json.dumps({"error": "Duplicate fact"})
updated_memory, created_fact = create_memory_fact_with_created_fact(
normalized_content,
category=category,
confidence=confidence,
agent_name=agent_name,
user_id=user_id,
)
fact_id = created_fact["id"]
if all(fact.get("id") != fact_id for fact in updated_memory.get("facts", [])):
return json.dumps({"error": "Fact was not stored because memory.max_facts kept higher-confidence facts"})
return json.dumps({"fact_id": fact_id, "status": "added"})
except ValueError as exc:
return json.dumps({"error": str(exc)})
except Exception as exc:
logger.exception("memory_add_tool failed")
return json.dumps({"error": str(exc)})
# Tool mode exposes explicit CRUD, not the passive staleness-review path.
# The staleness age/category/removal-count guardrails protect automatic
# middleware cleanup; tool-mode operators opt into model-directed updates
# and deletes. The docs call out this difference for configuration review.
@tool("memory_update", parse_docstring=True)
def memory_update_tool(
runtime: Runtime,
fact_id: str,
content: str | None = None,
category: str | None = None,
confidence: float | None = None,
) -> str:
"""Update an existing fact. Only provided fields are changed; omitted
fields stay as-is.
Use this when a stored fact is outdated, incorrect, or needs refinement.
First use memory_search to find the fact_id, then update it.
Args:
fact_id: Fact ID from memory_search results (required).
content: New fact text (unchanged if omitted).
category: New category (unchanged if omitted).
confidence: New confidence score 0.0-1.0 (unchanged if omitted).
Returns:
JSON string with "fact_id" and "status": "updated".
On invalid fact_id, returns "error" with explanation.
"""
agent_name, user_id = _resolve_scope(runtime)
try:
update_memory_fact(
fact_id,
content=content,
category=category,
confidence=confidence,
agent_name=agent_name,
user_id=user_id,
)
return json.dumps({"fact_id": fact_id, "status": "updated"})
except KeyError:
return json.dumps({"error": f"Fact not found: {fact_id}"})
except ValueError as exc:
return json.dumps({"error": str(exc)})
except Exception as exc:
logger.exception("memory_update_tool failed")
return json.dumps({"error": str(exc)})
@tool("memory_delete", parse_docstring=True)
def memory_delete_tool(runtime: Runtime, fact_id: str) -> str:
"""Delete a fact by its ID.
Use this when a fact is no longer accurate or relevant. First use
memory_search to find the fact_id, then delete it.
Args:
fact_id: Fact ID to delete (from memory_search results).
Returns:
JSON string with "fact_id" and "status": "deleted".
On invalid fact_id, returns "error" with explanation.
"""
agent_name, user_id = _resolve_scope(runtime)
try:
delete_memory_fact(fact_id, agent_name=agent_name, user_id=user_id)
return json.dumps({"fact_id": fact_id, "status": "deleted"})
except KeyError:
return json.dumps({"error": f"Fact not found: {fact_id}"})
except ValueError as exc:
return json.dumps({"error": str(exc)})
except Exception as exc:
logger.exception("memory_delete_tool failed")
return json.dumps({"error": str(exc)})
def get_memory_tools() -> list:
"""Return all memory tools for agent registration.
Called by agent factory when memory.mode == "tool".
"""
return [
memory_search_tool,
memory_add_tool,
memory_update_tool,
memory_delete_tool,
]

View File

@ -114,15 +114,27 @@ def _coerce_source_confidence(fact: dict[str, Any]) -> float:
return max(0.0, min(val, 1.0)) if math.isfinite(val) else 0.5
def create_memory_fact(
def _trim_facts_to_max(facts: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Keep the highest-confidence facts within the configured max_facts cap."""
config = get_memory_config()
if len(facts) <= config.max_facts:
return facts
return sorted(
facts,
key=_coerce_source_confidence,
reverse=True,
)[: config.max_facts]
def create_memory_fact_with_created_fact(
content: str,
category: str = "context",
confidence: float = 0.5,
agent_name: str | None = None,
*,
user_id: str | None = None,
) -> dict[str, Any]:
"""Create a new fact and persist the updated memory data."""
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Create a new fact, persist memory, and return both memory and fact."""
normalized_content = content.strip()
if not normalized_content:
raise ValueError("content")
@ -133,21 +145,39 @@ def create_memory_fact(
memory_data = get_memory_data(agent_name, user_id=user_id)
updated_memory = dict(memory_data)
facts = list(memory_data.get("facts", []))
facts.append(
{
"id": f"fact_{uuid.uuid4().hex[:8]}",
"content": normalized_content,
"category": normalized_category,
"confidence": validated_confidence,
"createdAt": now,
"source": "manual",
}
)
updated_memory["facts"] = facts
created_fact = {
"id": f"fact_{uuid.uuid4().hex[:8]}",
"content": normalized_content,
"category": normalized_category,
"confidence": validated_confidence,
"createdAt": now,
"source": "manual",
}
facts.append(created_fact)
updated_memory["facts"] = _trim_facts_to_max(facts)
if not _save_memory_to_file(updated_memory, agent_name, user_id=user_id):
raise OSError("Failed to save memory data after creating fact")
return updated_memory, created_fact
def create_memory_fact(
content: str,
category: str = "context",
confidence: float = 0.5,
agent_name: str | None = None,
*,
user_id: str | None = None,
) -> dict[str, Any]:
"""Create a new fact and persist the updated memory data."""
updated_memory, _created_fact = create_memory_fact_with_created_fact(
content,
category=category,
confidence=confidence,
agent_name=agent_name,
user_id=user_id,
)
return updated_memory
@ -168,6 +198,51 @@ def delete_memory_fact(fact_id: str, agent_name: str | None = None, *, user_id:
return updated_memory
def search_memory_facts(
query: str,
category: str | None = None,
limit: int = 10,
*,
agent_name: str | None = None,
user_id: str | None = None,
) -> list[dict[str, Any]]:
"""Search facts by case-insensitive substring match against content.
Args:
query: Substring to match (case-insensitive). Empty query returns [].
category: Optional category filter. If provided, only facts matching
this category are considered.
limit: Maximum results to return (default 10).
agent_name: Per-agent scope, or global memory if None.
user_id: Per-user scope within agent.
Returns:
List of matching fact dicts, sorted by confidence descending.
"""
if not query or not query.strip():
return []
if limit <= 0:
return []
query_lower = query.strip().lower()
memory_data = get_memory_data(agent_name, user_id=user_id)
facts = memory_data.get("facts", [])
matched = []
for fact in facts:
content = fact.get("content", "")
if not isinstance(content, str):
continue
if query_lower not in content.lower():
continue
if category is not None and fact.get("category") != category:
continue
matched.append(fact)
matched.sort(key=lambda f: f.get("confidence", 0), reverse=True)
return matched[:limit]
def update_memory_fact(
fact_id: str,
content: str | None = None,
@ -981,14 +1056,7 @@ class MemoryUpdater:
if fact_key is not None:
existing_fact_keys.add(fact_key)
# Enforce max facts limit
if len(current_memory["facts"]) > config.max_facts:
# Sort by confidence and keep top ones
current_memory["facts"] = sorted(
current_memory["facts"],
key=lambda f: f.get("confidence", 0),
reverse=True,
)[: config.max_facts]
current_memory["facts"] = _trim_facts_to_max(current_memory["facts"])
# ── Memory consolidation ──
# Runs after the max_facts trim so source facts that were just evicted

View File

@ -52,6 +52,12 @@ class MemoryConfig(BaseModel):
le=1.0,
description="Minimum confidence threshold for storing facts",
)
mode: Literal["middleware", "tool"] = Field(
default="middleware",
description=(
"Memory operation mode. 'middleware': passive LLM summarization after each turn (current behavior). 'tool': model calls memory tools (memory_search, memory_add, etc.) directly. Mutually exclusive — only one mode runs at a time."
),
)
injection_enabled: bool = Field(
default=True,
description="Whether to inject memory into system prompt",
@ -166,6 +172,11 @@ class MemoryConfig(BaseModel):
)
def should_use_memory_tools(config: MemoryConfig) -> bool:
"""Return True when memory should use model-directed tools."""
return config.enabled and config.mode == "tool"
# Global configuration instance
_memory_config: MemoryConfig = MemoryConfig()

View File

@ -103,6 +103,38 @@ def test_apply_prompt_template_includes_relative_path_guidance(monkeypatch):
assert "`hello.txt`, `../uploads/data.csv`, and `../outputs/report.md`" in prompt
def test_apply_prompt_template_includes_memory_tool_guidance_only_in_tool_mode(monkeypatch):
tool_config = SimpleNamespace(
sandbox=SimpleNamespace(mounts=[]),
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"),
acp_agents={},
)
middleware_config = SimpleNamespace(
sandbox=SimpleNamespace(mounts=[]),
skills=tool_config.skills,
skill_evolution=SimpleNamespace(enabled=False),
tool_search=SimpleNamespace(enabled=False),
memory=SimpleNamespace(enabled=True, mode="middleware"),
acp_agents={},
)
monkeypatch.setattr(prompt_module, "get_or_new_skill_storage", lambda app_config=None: SimpleNamespace(load_skills=lambda enabled_only=True: []))
monkeypatch.setattr(prompt_module, "get_or_new_user_skill_storage", lambda user_id, app_config=None: SimpleNamespace(load_skills=lambda *, enabled_only: []))
monkeypatch.setattr(prompt_module, "get_deferred_tools_prompt_section", lambda **kwargs: "")
monkeypatch.setattr(prompt_module, "_build_acp_section", lambda **kwargs: "")
monkeypatch.setattr(prompt_module, "get_agent_soul", lambda agent_name=None: "")
tool_prompt = prompt_module.apply_prompt_template(app_config=tool_config)
middleware_prompt = prompt_module.apply_prompt_template(app_config=middleware_config)
assert "<memory_tool_system>" in tool_prompt
assert "memory_search" in tool_prompt
assert "memory_add" in tool_prompt
assert "<memory_tool_system>" not in middleware_prompt
def test_apply_prompt_template_threads_explicit_app_config_without_global_config(monkeypatch):
mounts = [SimpleNamespace(container_path="/home/user/shared", read_only=False)]
explicit_config = SimpleNamespace(

View File

@ -0,0 +1,138 @@
"""Tests for search_memory_facts function."""
import json
from deerflow.agents.memory.storage import FileMemoryStorage, create_empty_memory
from deerflow.agents.memory.updater import search_memory_facts
def _make_fact(content: str, category: str = "context", confidence: float = 0.7) -> dict:
return {
"id": f"fact_test_{hash(content) & 0xFFFFFFFF:08x}",
"content": content,
"category": category,
"confidence": confidence,
"createdAt": "2026-07-09T00:00:00Z",
"source": "test",
}
class TestSearchMemoryFacts:
"""Tests for search_memory_facts function."""
def test_basic_substring_match(self, tmp_path, monkeypatch):
"""Should find facts containing the query string (case-insensitive)."""
facts = [
_make_fact("User prefers Python", "preference", 0.9),
_make_fact("User works with TypeScript", "context", 0.7),
_make_fact("User lives in Beijing", "personal", 0.8),
]
_setup_memory(tmp_path, monkeypatch, facts)
results = search_memory_facts("python")
assert len(results) == 1
assert results[0]["content"] == "User prefers Python"
def test_case_insensitive(self, tmp_path, monkeypatch):
"""Should match regardless of case."""
facts = [_make_fact("User prefers Python", "preference", 0.9)]
_setup_memory(tmp_path, monkeypatch, facts)
assert len(search_memory_facts("PYTHON")) == 1
assert len(search_memory_facts("python")) == 1
assert len(search_memory_facts("Python")) == 1
def test_category_filter(self, tmp_path, monkeypatch):
"""Should only return facts matching the given category."""
facts = [
_make_fact("Likes dark mode", "preference", 0.8),
_make_fact("Works remotely", "context", 0.7),
_make_fact("Prefers short answers", "preference", 0.6),
]
_setup_memory(tmp_path, monkeypatch, facts)
results = search_memory_facts("prefer", category="preference")
assert len(results) == 1
assert results[0]["content"] == "Prefers short answers"
def test_category_filter_no_match(self, tmp_path, monkeypatch):
"""Should return empty list when category doesn't match."""
facts = [_make_fact("Likes dark mode", "preference", 0.8)]
_setup_memory(tmp_path, monkeypatch, facts)
results = search_memory_facts("dark", category="context")
assert results == []
def test_empty_query_returns_empty(self, tmp_path, monkeypatch):
"""Should return empty list for empty query, not error."""
facts = [_make_fact("Some fact")]
_setup_memory(tmp_path, monkeypatch, facts)
results = search_memory_facts("")
assert results == []
def test_no_match_returns_empty(self, tmp_path, monkeypatch):
"""Should return empty list when nothing matches."""
facts = [_make_fact("User prefers Python")]
_setup_memory(tmp_path, monkeypatch, facts)
results = search_memory_facts("Rust")
assert results == []
def test_sorted_by_confidence_desc(self, tmp_path, monkeypatch):
"""Should return results sorted by confidence descending."""
facts = [
_make_fact("Fact A", confidence=0.3),
_make_fact("Fact B", confidence=0.9),
_make_fact("Fact C", confidence=0.6),
]
_setup_memory(tmp_path, monkeypatch, facts)
results = search_memory_facts("Fact")
assert len(results) == 3
assert results[0]["confidence"] == 0.9
assert results[1]["confidence"] == 0.6
assert results[2]["confidence"] == 0.3
def test_respects_limit(self, tmp_path, monkeypatch):
"""Should return at most `limit` results."""
facts = [_make_fact(f"Fact {i}", confidence=0.5) for i in range(20)]
_setup_memory(tmp_path, monkeypatch, facts)
results = search_memory_facts("Fact", limit=5)
assert len(results) == 5
def test_negative_limit_returns_empty(self, tmp_path, monkeypatch):
"""Should not let negative limits expand the result set via slicing."""
facts = [_make_fact(f"Fact {i}", confidence=0.5) for i in range(3)]
_setup_memory(tmp_path, monkeypatch, facts)
results = search_memory_facts("Fact", limit=-1)
assert results == []
def test_no_facts_returns_empty(self, tmp_path, monkeypatch):
"""Should return empty list when memory has no facts."""
_setup_memory(tmp_path, monkeypatch, [])
results = search_memory_facts("anything")
assert results == []
def _setup_memory(tmp_path, monkeypatch, facts: list[dict]):
"""Set up a FileMemoryStorage with given facts at a temp path."""
memory_file = tmp_path / "memory.json"
memory_data = create_empty_memory()
memory_data["facts"] = facts
memory_file.write_text(json.dumps(memory_data))
storage = FileMemoryStorage()
# Force the storage to use our temp file
monkeypatch.setattr(
"deerflow.agents.memory.updater.get_memory_storage",
lambda: storage,
)
monkeypatch.setattr(
"deerflow.agents.memory.updater.get_memory_data",
lambda agent_name=None, user_id=None: json.loads(memory_file.read_text()),
)

View File

@ -0,0 +1,486 @@
"""Tests for memory tool functions (tool-driven memory mode)."""
import json
from types import SimpleNamespace
from deerflow.agents.memory.tools import (
get_memory_tools,
memory_add_tool,
memory_delete_tool,
memory_search_tool,
memory_update_tool,
)
class _NamedTool:
def __init__(self, name: str):
self.name = name
class TestGetMemoryTools:
"""Tests for get_memory_tools registry."""
def test_returns_four_tools(self):
"""Should return exactly 4 tools."""
tools = get_memory_tools()
assert len(tools) == 4
def test_tools_have_unique_names(self):
"""All tools should have unique names."""
tools = get_memory_tools()
names = [t.name for t in tools]
assert len(names) == len(set(names))
assert "memory_search" in names
assert "memory_add" in names
assert "memory_update" in names
assert "memory_delete" in names
class TestMemorySearchTool:
"""Tests for memory_search tool handler."""
def test_returns_json_with_results(self, monkeypatch):
"""Should return JSON with results and count."""
mock_results = [
{"id": "fact_abc123", "content": "User likes Python", "category": "preference", "confidence": 0.9, "createdAt": "2026-01-01T00:00:00Z"},
]
def mock_search(query, category=None, limit=10, *, agent_name=None, user_id=None):
return mock_results
monkeypatch.setattr(
"deerflow.agents.memory.tools.search_memory_facts",
mock_search,
)
monkeypatch.setattr("deerflow.agents.memory.tools.resolve_runtime_user_id", lambda runtime: "test-user")
result_json = memory_search_tool.func(SimpleNamespace(context={}), "Python")
result = json.loads(result_json)
assert result["count"] == 1
assert len(result["results"]) == 1
assert result["results"][0]["id"] == "fact_abc123"
def test_empty_results(self, monkeypatch):
"""Should return empty results for no matches."""
monkeypatch.setattr(
"deerflow.agents.memory.tools.search_memory_facts",
lambda *a, **kw: [],
)
monkeypatch.setattr("deerflow.agents.memory.tools.resolve_runtime_user_id", lambda runtime: "test-user")
result_json = memory_search_tool.func(SimpleNamespace(context={}), "nothing")
result = json.loads(result_json)
assert result["count"] == 0
assert result["results"] == []
def test_runtime_error_returns_error_json(self, monkeypatch):
"""Should return error JSON when search raises RuntimeError."""
monkeypatch.setattr(
"deerflow.agents.memory.tools.search_memory_facts",
lambda *a, **kw: (_ for _ in ()).throw(RuntimeError("boom")),
)
monkeypatch.setattr("deerflow.agents.memory.tools.resolve_runtime_user_id", lambda runtime: "test-user")
result_json = memory_search_tool.func(SimpleNamespace(context={}), "anything")
result = json.loads(result_json)
assert "error" in result
assert result["error"] == "boom"
class TestMemoryAddTool:
"""Tests for memory_add tool handler."""
def test_adds_fact_and_returns_json(self, monkeypatch):
"""Should add a fact and return fact_id + status."""
created_fact = {"id": "fact_new123", "content": "User prefers dark mode"}
def mock_create(content, category="context", confidence=0.5, agent_name=None, *, user_id=None):
return {"facts": [created_fact]}, created_fact
monkeypatch.setattr("deerflow.agents.memory.tools.create_memory_fact_with_created_fact", mock_create)
monkeypatch.setattr("deerflow.agents.memory.tools.get_memory_data", lambda *a, **kw: {"facts": []})
monkeypatch.setattr("deerflow.agents.memory.tools.resolve_runtime_user_id", lambda runtime: "test-user")
result_json = memory_add_tool.func(SimpleNamespace(context={}), "User prefers dark mode", category="preference", confidence=0.9)
result = json.loads(result_json)
assert result["status"] == "added"
assert result["fact_id"] == "fact_new123"
def test_add_returns_created_fact_id_when_storage_reorders_facts(self, monkeypatch):
"""Should not infer the created fact from the final facts ordering."""
created_fact = {"id": "fact_new123", "content": "User prefers dark mode"}
def mock_create(content, category="context", confidence=0.5, agent_name=None, *, user_id=None):
return {"facts": [created_fact, {"id": "fact_old999", "content": "Older fact"}]}, created_fact
monkeypatch.setattr("deerflow.agents.memory.tools.create_memory_fact_with_created_fact", mock_create)
monkeypatch.setattr("deerflow.agents.memory.tools.get_memory_data", lambda *a, **kw: {"facts": []})
monkeypatch.setattr("deerflow.agents.memory.tools.resolve_runtime_user_id", lambda runtime: "test-user")
result_json = memory_add_tool.func(SimpleNamespace(context={}), "User prefers dark mode")
result = json.loads(result_json)
assert result["fact_id"] == "fact_new123"
def test_uses_runtime_user_id_when_directly_called(self, monkeypatch):
"""Should prefer runtime.context user_id over ContextVar fallback."""
captured = {}
def mock_create(content, category="context", confidence=0.5, agent_name=None, *, user_id=None):
captured["agent_name"] = agent_name
captured["user_id"] = user_id
return {"facts": [{"id": "fact_new123", "content": content}]}, {"id": "fact_new123", "content": content}
monkeypatch.setattr("deerflow.agents.memory.tools.create_memory_fact_with_created_fact", mock_create)
monkeypatch.setattr("deerflow.agents.memory.tools.get_memory_data", lambda *a, **kw: {"facts": []})
runtime = SimpleNamespace(context={"user_id": "runtime-user", "agent_name": "code-agent"})
result_json = memory_add_tool.func(runtime, "User prefers dark mode")
result = json.loads(result_json)
assert result["status"] == "added"
assert captured == {"agent_name": "code-agent", "user_id": "runtime-user"}
def test_rejects_existing_duplicate_content(self, monkeypatch):
"""Should not persist a fact whose normalized content already exists."""
create_called = False
def mock_create(*a, **kw):
nonlocal create_called
create_called = True
return {"facts": [{"id": "fact_new123"}]}, {"id": "fact_new123"}
monkeypatch.setattr(
"deerflow.agents.memory.tools.get_memory_data",
lambda *a, **kw: {"facts": [{"id": "fact_existing", "content": "User prefers dark mode"}]},
)
monkeypatch.setattr("deerflow.agents.memory.tools.create_memory_fact_with_created_fact", mock_create)
result_json = memory_add_tool.func(SimpleNamespace(context={}), " User prefers dark mode ")
result = json.loads(result_json)
assert "error" in result
assert create_called is False
def test_rejects_duplicate_content_outside_search_limit(self, monkeypatch):
"""Should full-scan exact duplicates before persisting a new fact."""
facts = [
{
"id": f"fact_high_{idx}",
"content": f"User prefers dark mode with variant {idx}",
"category": "preference",
"confidence": 1.0 - (idx * 0.01),
}
for idx in range(10)
]
facts.append(
{
"id": "fact_exact",
"content": "User prefers dark mode",
"category": "preference",
"confidence": 0.1,
}
)
create_called = False
def mock_get_memory_data(agent_name=None, *, user_id=None):
return {"facts": facts}
def mock_create(*a, **kw):
nonlocal create_called
create_called = True
return {"facts": []}, {"id": "fact_new"}
monkeypatch.setattr("deerflow.agents.memory.tools.get_memory_data", mock_get_memory_data)
monkeypatch.setattr("deerflow.agents.memory.tools.create_memory_fact_with_created_fact", mock_create)
monkeypatch.setattr("deerflow.agents.memory.tools.resolve_runtime_user_id", lambda runtime: "test-user")
result_json = memory_add_tool.func(SimpleNamespace(context={}), " User prefers dark mode ")
result = json.loads(result_json)
assert result == {"error": "Duplicate fact"}
assert create_called is False
def test_duplicate_content_returns_error(self, monkeypatch):
"""Should return error JSON for duplicate content."""
def mock_create(*a, **kw):
raise ValueError("Duplicate fact")
monkeypatch.setattr("deerflow.agents.memory.tools.create_memory_fact_with_created_fact", mock_create)
monkeypatch.setattr("deerflow.agents.memory.tools.get_memory_data", lambda *a, **kw: {"facts": []})
monkeypatch.setattr("deerflow.agents.memory.tools.resolve_runtime_user_id", lambda runtime: "test-user")
result_json = memory_add_tool.func(SimpleNamespace(context={}), "duplicate")
result = json.loads(result_json)
assert "error" in result
def test_empty_content_returns_error(self, monkeypatch):
"""Should return error JSON for empty content."""
def mock_create(*a, **kw):
raise ValueError("content")
monkeypatch.setattr("deerflow.agents.memory.tools.create_memory_fact_with_created_fact", mock_create)
monkeypatch.setattr("deerflow.agents.memory.tools.get_memory_data", lambda *a, **kw: {"facts": []})
monkeypatch.setattr("deerflow.agents.memory.tools.resolve_runtime_user_id", lambda runtime: "test-user")
result_json = memory_add_tool.func(SimpleNamespace(context={}), "")
result = json.loads(result_json)
assert "error" in result
class TestMemoryUpdateTool:
"""Tests for memory_update tool handler."""
def test_updates_fact_and_returns_json(self, monkeypatch):
"""Should update a fact and return JSON."""
mock_memory = {"facts": [{"id": "fact_abc", "content": "updated content"}]}
def mock_update(fact_id, content=None, category=None, confidence=None, agent_name=None, *, user_id=None):
return mock_memory
monkeypatch.setattr("deerflow.agents.memory.tools.update_memory_fact", mock_update)
monkeypatch.setattr("deerflow.agents.memory.tools.resolve_runtime_user_id", lambda runtime: "test-user")
result_json = memory_update_tool.func(SimpleNamespace(context={}), "fact_abc", content="updated content")
result = json.loads(result_json)
assert result["status"] == "updated"
assert result["fact_id"] == "fact_abc"
def test_invalid_fact_id_returns_error(self, monkeypatch):
"""Should return error JSON for invalid fact_id."""
def mock_update(*a, **kw):
raise KeyError("fact_xxx")
monkeypatch.setattr("deerflow.agents.memory.tools.update_memory_fact", mock_update)
monkeypatch.setattr("deerflow.agents.memory.tools.resolve_runtime_user_id", lambda runtime: "test-user")
result_json = memory_update_tool.func(SimpleNamespace(context={}), "fact_xxx", content="nope")
result = json.loads(result_json)
assert "error" in result
assert "fact_xxx" in result["error"]
class TestMemoryDeleteTool:
"""Tests for memory_delete tool handler."""
def test_deletes_fact_and_returns_json(self, monkeypatch):
"""Should delete a fact and return JSON."""
mock_memory = {"facts": []}
def mock_delete(fact_id, agent_name=None, *, user_id=None):
return mock_memory
monkeypatch.setattr("deerflow.agents.memory.tools.delete_memory_fact", mock_delete)
monkeypatch.setattr("deerflow.agents.memory.tools.resolve_runtime_user_id", lambda runtime: "test-user")
result_json = memory_delete_tool.func(SimpleNamespace(context={}), "fact_abc")
result = json.loads(result_json)
assert result["status"] == "deleted"
assert result["fact_id"] == "fact_abc"
def test_invalid_fact_id_returns_error(self, monkeypatch):
"""Should return error JSON for invalid fact_id."""
def mock_delete(*a, **kw):
raise KeyError("fact_xxx")
monkeypatch.setattr("deerflow.agents.memory.tools.delete_memory_fact", mock_delete)
monkeypatch.setattr("deerflow.agents.memory.tools.resolve_runtime_user_id", lambda runtime: "test-user")
result_json = memory_delete_tool.func(SimpleNamespace(context={}), "fact_xxx")
result = json.loads(result_json)
assert "error" in result
assert "fact_xxx" in result["error"]
class TestModeGating:
"""Integration tests for memory.mode exclusivity."""
def test_tool_mode_registers_tools_not_middleware(self, monkeypatch):
"""When mode=tool, get_memory_tools are added to extra_tools and
MemoryMiddleware is NOT in the chain."""
from deerflow.agents.factory import _assemble_from_features
from deerflow.agents.features import RuntimeFeatures
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.config.memory_config import MemoryConfig
tool_config = MemoryConfig(enabled=True, mode="tool")
monkeypatch.setattr(
"deerflow.config.memory_config.get_memory_config",
lambda: tool_config,
)
feat = RuntimeFeatures(memory=True)
chain, extra_tools = _assemble_from_features(feat, name="test-agent")
middleware_types = [type(m) for m in chain]
assert MemoryMiddleware not in middleware_types, "MemoryMiddleware should not be in the chain in tool mode"
tool_names = [t.name for t in extra_tools]
assert "memory_search" in tool_names
assert "memory_add" in tool_names
assert "memory_update" in tool_names
assert "memory_delete" in tool_names
def test_explicit_memory_config_drives_factory_mode(self, monkeypatch):
"""Factory mode gating should use the explicit config before ambient globals."""
from deerflow.agents.factory import _assemble_from_features
from deerflow.agents.features import RuntimeFeatures
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.config.memory_config import MemoryConfig
monkeypatch.setattr(
"deerflow.config.memory_config.get_memory_config",
lambda: MemoryConfig(enabled=True, mode="middleware"),
)
feat = RuntimeFeatures(memory=True, memory_config=MemoryConfig(enabled=True, mode="tool"))
chain, extra_tools = _assemble_from_features(feat, name="test-agent")
middleware_types = [type(m) for m in chain]
tool_names = [t.name for t in extra_tools]
assert MemoryMiddleware not in middleware_types
assert "memory_add" in tool_names
def test_middleware_mode_appends_middleware_not_tools(self, monkeypatch):
"""When mode=middleware (default), MemoryMiddleware IS in the chain
and memory tools are NOT in extra_tools."""
from deerflow.agents.factory import _assemble_from_features
from deerflow.agents.features import RuntimeFeatures
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.config.memory_config import MemoryConfig
mw_config = MemoryConfig(enabled=True, mode="middleware")
monkeypatch.setattr(
"deerflow.config.memory_config.get_memory_config",
lambda: mw_config,
)
feat = RuntimeFeatures(memory=True)
chain, extra_tools = _assemble_from_features(feat, name="test-agent")
middleware_types = [type(m) for m in chain]
assert MemoryMiddleware in middleware_types, "MemoryMiddleware should be in the chain in middleware mode"
tool_names = [t.name for t in extra_tools]
assert "memory_search" not in tool_names, "memory_search should not be registered in middleware mode"
def test_memory_disabled_skips_both(self, monkeypatch):
"""When memory.enabled=False, middleware IS appended but no-ops at
runtime (the enabled check is inside after_agent, not the factory).
Tools are never registered because mode is middleware (default)."""
from deerflow.agents.factory import _assemble_from_features
from deerflow.agents.features import RuntimeFeatures
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.config.memory_config import MemoryConfig
disabled_config = MemoryConfig(enabled=False, mode="middleware")
monkeypatch.setattr(
"deerflow.config.memory_config.get_memory_config",
lambda: disabled_config,
)
feat = RuntimeFeatures(memory=True)
chain, extra_tools = _assemble_from_features(feat, name="test-agent")
# Middleware is appended — it checks enabled internally in after_agent
middleware_types = [type(m) for m in chain]
assert MemoryMiddleware in middleware_types
# Tools should NOT be registered in middleware mode regardless of enabled
tool_names = [t.name for t in extra_tools]
assert "memory_search" not in tool_names
def test_should_use_memory_tools_requires_tool_mode_and_enabled(self):
"""Tool-mode helper should require both mode=tool and enabled=True."""
from deerflow.config.memory_config import MemoryConfig, should_use_memory_tools
assert should_use_memory_tools(MemoryConfig(enabled=True, mode="tool")) is True
assert should_use_memory_tools(MemoryConfig(enabled=False, mode="tool")) is False
assert should_use_memory_tools(MemoryConfig(enabled=True, mode="middleware")) is False
def test_tool_mode_disabled_logs_warning_and_uses_middleware(self, monkeypatch, caplog):
"""mode=tool with enabled=False should be visible and still disable tools."""
from deerflow.agents.factory import _assemble_from_features
from deerflow.agents.features import RuntimeFeatures
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
from deerflow.config.memory_config import MemoryConfig
disabled_tool_config = MemoryConfig(enabled=False, mode="tool")
monkeypatch.setattr(
"deerflow.config.memory_config.get_memory_config",
lambda: disabled_tool_config,
)
chain, extra_tools = _assemble_from_features(RuntimeFeatures(memory=True), name="test-agent")
assert MemoryMiddleware in [type(m) for m in chain]
assert "memory_add" not in [t.name for t in extra_tools]
assert "memory.mode is 'tool' but memory.enabled is false" in caplog.text
def test_lead_agent_deduplicates_memory_tools_after_appending(self, monkeypatch):
"""Configured tools should not duplicate tool-mode memory tools."""
from deerflow.agents.lead_agent import agent as lead_agent_module
from deerflow.config.memory_config import MemoryConfig
monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda x=None, **kwargs: "default-model")
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: "model")
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda *args, **kwargs: [])
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", lambda **kwargs: "mock_prompt")
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
monkeypatch.setattr(lead_agent_module, "build_tracing_callbacks", lambda: [])
monkeypatch.setattr(
lead_agent_module,
"load_agent_config",
lambda name: SimpleNamespace(model=None, skills=None, tool_groups=None),
)
monkeypatch.setattr(lead_agent_module, "_load_enabled_skills_for_tool_policy", lambda available_skills, *, app_config, user_id=None: [])
monkeypatch.setattr(lead_agent_module, "filter_tools_by_skill_allowed_tools", lambda tools, skills, always_allowed_tool_names=(): tools)
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: [_NamedTool("memory_search"), _NamedTool("bash")])
app_config = SimpleNamespace(
get_model_config=lambda name: SimpleNamespace(supports_thinking=False, supports_vision=False),
memory=MemoryConfig(enabled=True, mode="tool"),
skills=SimpleNamespace(deferred_discovery=False, container_path="/tmp/skills"),
tool_search=SimpleNamespace(enabled=False, auto_promote_top_k=0),
)
agent_kwargs = lead_agent_module._make_lead_agent({"configurable": {"agent_name": "test-agent"}}, app_config=app_config)
tool_names = [tool.name for tool in agent_kwargs["tools"]]
assert tool_names.count("memory_search") == 1
assert "memory_add" in tool_names
def test_lead_agent_preserves_non_memory_duplicate_tool_names(self, monkeypatch):
"""Memory-tool collision handling should not drop unrelated duplicate tools."""
from deerflow.agents.lead_agent import agent as lead_agent_module
from deerflow.config.memory_config import MemoryConfig
monkeypatch.setattr(lead_agent_module, "_resolve_model_name", lambda x=None, **kwargs: "default-model")
monkeypatch.setattr(lead_agent_module, "create_chat_model", lambda **kwargs: "model")
monkeypatch.setattr(lead_agent_module, "build_middlewares", lambda *args, **kwargs: [])
monkeypatch.setattr(lead_agent_module, "apply_prompt_template", lambda **kwargs: "mock_prompt")
monkeypatch.setattr(lead_agent_module, "create_agent", lambda **kwargs: kwargs)
monkeypatch.setattr(lead_agent_module, "build_tracing_callbacks", lambda: [])
monkeypatch.setattr(
lead_agent_module,
"load_agent_config",
lambda name: SimpleNamespace(model=None, skills=None, tool_groups=None),
)
monkeypatch.setattr(lead_agent_module, "_load_enabled_skills_for_tool_policy", lambda available_skills, *, app_config, user_id=None: [])
monkeypatch.setattr(lead_agent_module, "filter_tools_by_skill_allowed_tools", lambda tools, skills, always_allowed_tool_names=(): tools)
monkeypatch.setattr("deerflow.tools.get_available_tools", lambda **kwargs: [_NamedTool("bash"), _NamedTool("bash")])
app_config = SimpleNamespace(
get_model_config=lambda name: SimpleNamespace(supports_thinking=False, supports_vision=False),
memory=MemoryConfig(enabled=True, mode="tool"),
skills=SimpleNamespace(deferred_discovery=False, container_path="/tmp/skills"),
tool_search=SimpleNamespace(enabled=False, auto_promote_top_k=0),
)
agent_kwargs = lead_agent_module._make_lead_agent({"configurable": {"agent_name": "test-agent"}}, app_config=app_config)
tool_names = [tool.name for tool in agent_kwargs["tools"]]
assert tool_names.count("bash") == 2
assert tool_names.count("memory_add") == 1

View File

@ -8,6 +8,7 @@ from deerflow.agents.memory.updater import (
_extract_text,
clear_memory_data,
create_memory_fact,
create_memory_fact_with_created_fact,
delete_memory_fact,
import_memory_data,
update_memory_fact,
@ -311,6 +312,52 @@ def test_create_memory_fact_appends_manual_fact() -> None:
assert result["facts"][0]["source"] == "manual"
def test_create_memory_fact_trims_to_max_facts_by_confidence() -> None:
existing = _make_memory(
facts=[
{"id": "fact_keep", "content": "High confidence", "category": "context", "confidence": 0.95},
{"id": "fact_drop", "content": "Low confidence", "category": "context", "confidence": 0.2},
]
)
saved: dict[str, object] = {}
def capture_save(memory_data, agent_name=None, *, user_id=None):
saved["memory"] = memory_data
return True
with (
patch("deerflow.agents.memory.updater.get_memory_data", return_value=existing),
patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(max_facts=2)),
patch("deerflow.agents.memory.updater._save_memory_to_file", side_effect=capture_save),
):
result = create_memory_fact(content="Medium confidence", confidence=0.8)
fact_ids = [fact["id"] for fact in result["facts"]]
assert len(fact_ids) == 2
assert fact_ids == ["fact_keep", result["facts"][1]["id"]]
assert all(fact["id"] != "fact_drop" for fact in result["facts"])
assert saved["memory"] == result
def test_create_memory_fact_with_created_fact_returns_new_fact_after_sorting() -> None:
existing = _make_memory(
facts=[
{"id": "fact_existing", "content": "Higher confidence", "category": "context", "confidence": 0.95},
]
)
with (
patch("deerflow.agents.memory.updater.get_memory_data", return_value=existing),
patch("deerflow.agents.memory.updater.get_memory_config", return_value=_memory_config(max_facts=2)),
patch("deerflow.agents.memory.updater._save_memory_to_file", return_value=True),
):
result, created_fact = create_memory_fact_with_created_fact(content="Lower confidence", confidence=0.7)
assert result["facts"][0]["id"] == "fact_existing"
assert created_fact["content"] == "Lower confidence"
assert created_fact["id"] == result["facts"][1]["id"]
def test_create_memory_fact_rejects_empty_content() -> None:
try:
create_memory_fact(content=" ")

View File

@ -15,7 +15,7 @@
# ============================================================================
# Bump this number when the config schema changes.
# Run `make config-upgrade` to merge new fields into your local config.yaml.
config_version: 21
config_version: 22
# ============================================================================
# Logging
@ -1387,6 +1387,13 @@ summarization:
# Stores user context and conversation history for personalized responses
memory:
enabled: true
# Memory operation mode:
# middleware (default) - passive background extraction after each turn.
# tool - experimental opt-in; the model calls memory_search/memory_add/
# memory_update/memory_delete directly. This gives the model agency over
# memory writes, but effectiveness depends on model tool-use behavior.
# Only one mode runs at a time.
mode: middleware
storage_path: memory.json # Absolute path opts out of per-user isolation; a relative path resolves under the data base_dir, not the backend directory
debounce_seconds: 30 # Wait time before processing queued updates
model_name: null # Use default model

View File

@ -103,7 +103,7 @@ they resolve from the `secrets` map):
```yaml
config: |
config_version: 20
config_version: 22
models:
- name: gpt-4
use: langchain_openai:ChatOpenAI

View File

@ -221,7 +221,7 @@ ingress:
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
# inline literal secret values here. The default enables provisioner sandbox.
config: |
config_version: 21
config_version: 22
log_level: info
models: []

View File

@ -29,11 +29,14 @@ Each category is updated over time as the agent learns from ongoing conversation
## How it works
Memory is managed by `MemoryMiddleware`, which runs on every Lead Agent turn:
Memory has two operation modes:
1. **Injection**: 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. **Learning**: after a conversation, a background job extracts new facts and updates the relevant memory categories. Updates are debounced by `debounce_seconds` to batch rapid changes.
3. **Per-agent memory**: when a custom agent is active, its memory is stored separately from the global memory. This keeps different agents' knowledge isolated.
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.
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.
## Configuration
@ -41,6 +44,11 @@ Memory is managed by `MemoryMiddleware`, which runs on every Lead Agent turn:
memory:
enabled: true
# Operation mode:
# middleware - default; passive background extraction after each turn
# tool - experimental; model calls memory_* tools directly
mode: middleware
# Storage path for the global memory file.
# Default: {base_dir}/memory.json (resolves to backend/.deer-flow/memory.json)
# Absolute paths are used as-is.

View File

@ -16,6 +16,7 @@ In `config.yaml`:
```yaml
memory:
enabled: true
mode: middleware
injection_enabled: true
max_injection_tokens: 2000
debounce_seconds: 30
@ -23,12 +24,14 @@ memory:
## How memory works
Memory works automatically through `MemoryMiddleware`:
By default, memory works automatically through `MemoryMiddleware`:
1. **First conversation**: tell the agent about your preferences, project, or background.
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.
## Example
**First conversation:**

View File

@ -28,11 +28,14 @@ import { Callout } from "nextra/components";
## 工作原理
记忆由 `MemoryMiddleware` 管理,在每次 Lead Agent 轮次上运行:
记忆支持两种运行模式:
1. **注入**:每次对话开始时,Agent 当前记忆以受控的 token 预算(`max_injection_tokens`)注入到系统提示中。
2. **学习**:对话结束后,后台任务提取新事实并更新相关记忆类别。更新通过 `debounce_seconds` 防抖以批量处理快速变化。
3. **按 Agent 记忆**:当自定义 Agent 激活时,其记忆独立于全局记忆存储。这保持不同 Agent 的知识隔离。
1. **两种模式都会注入**:每次对话开始时,Agent 当前记忆以受控的 token 预算(`max_injection_tokens`)注入到系统提示中。
2. **中间件模式(默认)**:`MemoryMiddleware` 在每次 Lead Agent 轮次结束后运行,过滤对话、排队后台更新并自动提取新事实。更新通过 `debounce_seconds` 防抖以批量处理快速变化。
3. **工具模式(实验性)**:DeerFlow 注册 `memory_search`、`memory_add`、`memory_update`、`memory_delete` 四个工具,并跳过 `MemoryMiddleware`。模型自行决定何时搜索或写入记忆,因此效果取决于模型的工具调用行为。这些显式 CRUD 工具不是被动的陈旧性复核路径;启用工具模式意味着运营方选择由模型主动更新/删除记忆,而不是依赖中间件的陈旧性保护栏。
4. **按 Agent 记忆**:当自定义 Agent 激活时,其记忆独立于全局记忆存储。这保持不同 Agent 的知识隔离。
在工具模式中,四个 `memory_*` 工具名是保留名。如果 MCP 服务或自定义工具已经使用 `memory_search`、`memory_add`、`memory_update` 或 `memory_delete`,已有工具会保留该名称,冲突的记忆工具会被跳过并记录告警。
## 配置
@ -40,6 +43,11 @@ import { Callout } from "nextra/components";
memory:
enabled: true
# 运行模式:
# middleware - 默认;每轮结束后被动后台提取
# tool - 实验性;模型直接调用 memory_* 工具
mode: middleware
# 全局记忆文件的存储路径。
# 默认:{base_dir}/memory.json(解析为 backend/.deer-flow/memory.json)
# 绝对路径按原样使用,相对路径相对于 base_dir 解析。

View File

@ -14,6 +14,7 @@ description: 本教程介绍如何在 DeerFlow 中启用和使用记忆系统,
```yaml
memory:
enabled: true
mode: middleware
injection_enabled: true
max_injection_tokens: 2000
debounce_seconds: 30
@ -21,12 +22,14 @@ memory:
## 记忆的工作方式
记忆通过 `MemoryMiddleware` 自动工作:
默认情况下,记忆通过 `MemoryMiddleware` 自动工作:
1. **第一次对话**:告诉 Agent 关于你的偏好、项目或背景。
2. **自动学习**:Agent 在后台提取并保存重要事实。
3. **后续对话**:记忆事实自动注入到系统提示中,Agent 无需你重新解释背景。
DeerFlow 也提供实验性的 `mode: tool`。在工具模式中,Agent 会获得 `memory_search`、`memory_add`、`memory_update` 和 `memory_delete` 工具,并自行决定何时调用。除非你明确想评估模型主动管理记忆的行为,否则建议保持 `mode: middleware`。
## 示例
**第一次对话**: