mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-26 16:07:53 +00:00
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:
parent
bbb3deb231
commit
c2002d9fac
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
228
backend/packages/harness/deerflow/agents/memory/tools.py
Normal file
228
backend/packages/harness/deerflow/agents/memory/tools.py
Normal 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,
|
||||
]
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
138
backend/tests/test_memory_search.py
Normal file
138
backend/tests/test_memory_search.py
Normal 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()),
|
||||
)
|
||||
486
backend/tests/test_memory_tools.py
Normal file
486
backend/tests/test_memory_tools.py
Normal 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
|
||||
@ -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=" ")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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: []
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:**
|
||||
|
||||
@ -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 解析。
|
||||
|
||||
@ -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`。
|
||||
|
||||
## 示例
|
||||
|
||||
**第一次对话**:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user