From c2002d9fac48b39355aff46a5a015d59f883ccde Mon Sep 17 00:00:00 2001 From: Vanzeren <53075619+Vanzeren@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:28:16 +0800 Subject: [PATCH] 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 --- backend/AGENTS.md | 18 +- .../harness/deerflow/agents/factory.py | 24 +- .../harness/deerflow/agents/features.py | 8 +- .../deerflow/agents/lead_agent/agent.py | 27 +- .../deerflow/agents/lead_agent/prompt.py | 32 ++ .../deerflow/agents/memory/__init__.py | 15 + .../harness/deerflow/agents/memory/tools.py | 228 ++++++++ .../harness/deerflow/agents/memory/updater.py | 112 +++- .../harness/deerflow/config/memory_config.py | 11 + backend/tests/test_lead_agent_prompt.py | 32 ++ backend/tests/test_memory_search.py | 138 +++++ backend/tests/test_memory_tools.py | 486 ++++++++++++++++++ backend/tests/test_memory_updater.py | 47 ++ config.example.yaml | 9 +- deploy/helm/deer-flow/README.md | 2 +- deploy/helm/deer-flow/values.yaml | 2 +- frontend/src/content/en/harness/memory.mdx | 16 +- .../content/en/tutorials/work-with-memory.mdx | 5 +- frontend/src/content/zh/harness/memory.mdx | 16 +- .../content/zh/tutorials/work-with-memory.mdx | 5 +- 20 files changed, 1185 insertions(+), 48 deletions(-) create mode 100644 backend/packages/harness/deerflow/agents/memory/tools.py create mode 100644 backend/tests/test_memory_search.py create mode 100644 backend/tests/test_memory_tools.py diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 43121fb1c..1af0d8c08 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -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 `` 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 `` 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) diff --git a/backend/packages/harness/deerflow/agents/factory.py b/backend/packages/harness/deerflow/agents/factory.py index e75b32436..b03490e66 100644 --- a/backend/packages/harness/deerflow/agents/factory.py +++ b/backend/packages/harness/deerflow/agents/factory.py @@ -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: diff --git a/backend/packages/harness/deerflow/agents/features.py b/backend/packages/harness/deerflow/agents/features.py index 5aca6cf06..6a79e5303 100644 --- a/backend/packages/harness/deerflow/agents/features.py +++ b/backend/packages/harness/deerflow/agents/features.py @@ -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 diff --git a/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/backend/packages/harness/deerflow/agents/lead_agent/agent.py index ce538d86c..e113cdf8f 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -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, diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 5a25c8c92..2bb15cfc6 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -538,6 +538,8 @@ You: "Deploying to staging..." [proceed] {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 is running in tool mode. Use the injected 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. +""" + + 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 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, diff --git a/backend/packages/harness/deerflow/agents/memory/__init__.py b/backend/packages/harness/deerflow/agents/memory/__init__.py index 36f31bb72..c4b034ece 100644 --- a/backend/packages/harness/deerflow/agents/memory/__init__.py +++ b/backend/packages/harness/deerflow/agents/memory/__init__.py @@ -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", ] diff --git a/backend/packages/harness/deerflow/agents/memory/tools.py b/backend/packages/harness/deerflow/agents/memory/tools.py new file mode 100644 index 000000000..dd5277540 --- /dev/null +++ b/backend/packages/harness/deerflow/agents/memory/tools.py @@ -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, + ] diff --git a/backend/packages/harness/deerflow/agents/memory/updater.py b/backend/packages/harness/deerflow/agents/memory/updater.py index 695c7cc6d..a6681f4cf 100644 --- a/backend/packages/harness/deerflow/agents/memory/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/updater.py @@ -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 diff --git a/backend/packages/harness/deerflow/config/memory_config.py b/backend/packages/harness/deerflow/config/memory_config.py index c90f15a01..379afcc94 100644 --- a/backend/packages/harness/deerflow/config/memory_config.py +++ b/backend/packages/harness/deerflow/config/memory_config.py @@ -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() diff --git a/backend/tests/test_lead_agent_prompt.py b/backend/tests/test_lead_agent_prompt.py index 0d2ce8248..65db7cf0f 100644 --- a/backend/tests/test_lead_agent_prompt.py +++ b/backend/tests/test_lead_agent_prompt.py @@ -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 "" in tool_prompt + assert "memory_search" in tool_prompt + assert "memory_add" in tool_prompt + assert "" 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( diff --git a/backend/tests/test_memory_search.py b/backend/tests/test_memory_search.py new file mode 100644 index 000000000..9801e3bf0 --- /dev/null +++ b/backend/tests/test_memory_search.py @@ -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()), + ) diff --git a/backend/tests/test_memory_tools.py b/backend/tests/test_memory_tools.py new file mode 100644 index 000000000..a1da17413 --- /dev/null +++ b/backend/tests/test_memory_tools.py @@ -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 diff --git a/backend/tests/test_memory_updater.py b/backend/tests/test_memory_updater.py index 047a3a4b6..7223fab55 100644 --- a/backend/tests/test_memory_updater.py +++ b/backend/tests/test_memory_updater.py @@ -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=" ") diff --git a/config.example.yaml b/config.example.yaml index 039396104..b5409f1ec 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -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 diff --git a/deploy/helm/deer-flow/README.md b/deploy/helm/deer-flow/README.md index 7a1ac815f..c972cf0b2 100644 --- a/deploy/helm/deer-flow/README.md +++ b/deploy/helm/deer-flow/README.md @@ -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 diff --git a/deploy/helm/deer-flow/values.yaml b/deploy/helm/deer-flow/values.yaml index 4f40f8a5b..65b09fcc9 100644 --- a/deploy/helm/deer-flow/values.yaml +++ b/deploy/helm/deer-flow/values.yaml @@ -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: [] diff --git a/frontend/src/content/en/harness/memory.mdx b/frontend/src/content/en/harness/memory.mdx index cdb15d087..7659f97b2 100644 --- a/frontend/src/content/en/harness/memory.mdx +++ b/frontend/src/content/en/harness/memory.mdx @@ -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. diff --git a/frontend/src/content/en/tutorials/work-with-memory.mdx b/frontend/src/content/en/tutorials/work-with-memory.mdx index c7aa047db..32e19e6a1 100644 --- a/frontend/src/content/en/tutorials/work-with-memory.mdx +++ b/frontend/src/content/en/tutorials/work-with-memory.mdx @@ -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:** diff --git a/frontend/src/content/zh/harness/memory.mdx b/frontend/src/content/zh/harness/memory.mdx index e168a7d38..4e107d43d 100644 --- a/frontend/src/content/zh/harness/memory.mdx +++ b/frontend/src/content/zh/harness/memory.mdx @@ -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 解析。 diff --git a/frontend/src/content/zh/tutorials/work-with-memory.mdx b/frontend/src/content/zh/tutorials/work-with-memory.mdx index ac3667af8..a2d5d86d7 100644 --- a/frontend/src/content/zh/tutorials/work-with-memory.mdx +++ b/frontend/src/content/zh/tutorials/work-with-memory.mdx @@ -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`。 + ## 示例 **第一次对话**: