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`。
## 示例
**第一次对话**