diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deer_mem.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deer_mem.py
index 8ecacbc0f..ddb4ffd7d 100644
--- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deer_mem.py
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deer_mem.py
@@ -34,8 +34,9 @@ from .deermem.core.message_processing import (
detect_correction,
detect_reinforcement,
filter_messages_for_memory,
+ load_patterns,
)
-from .deermem.core.prompt import format_memory_for_injection, warm_tiktoken_cache
+from .deermem.core.prompt import format_memory_for_injection, load_prompt, load_prompt_messages, warm_tiktoken_cache
from .deermem.core.queue import MemoryUpdateQueue
from .deermem.core.storage import create_storage
from .deermem.core.updater import MemoryUpdater, _coerce_source_confidence
@@ -56,11 +57,33 @@ class DeerMem(MemoryManager):
"""
self._config = DeerMemConfig.from_backend_config(backend_config)
self._storage = create_storage(self._config)
+ # Signal-detection patterns (externalized YAML; ``patterns_dir`` override
+ # or bundled defaults = pre-externalization behavior). Loaded once at
+ # construction and reused by ``_prepare_update``'s detect_* calls.
+ self._correction_patterns = load_patterns("correction", patterns_dir=self._config.patterns_dir)
+ self._reinforcement_patterns = load_patterns("reinforcement", patterns_dir=self._config.patterns_dir)
# host_llm (host-injected default model) takes precedence over build_llm(model)
# so zero-config DeerMem (empty `model`) still extracts via the app default,
# mirroring pre-abstraction `model_name: null`. Standalone (no factory) -> None.
self._llm = self._config.host_llm if self._config.host_llm is not None else build_llm(self._config.model)
- self._updater = MemoryUpdater(self._config, self._storage, self._llm)
+ self._updater = MemoryUpdater(self._config, self._storage, self._llm, prompts_dir=self._config.prompts_dir)
+ # Validate the *global* explicit prompt templates at construction so a
+ # misconfigured prompts_dir surfaces at startup rather than as a silent
+ # dropped update. Per-agent overrides ({prompts_dir}/{agent}/*.yaml)
+ # cannot be known here -- they are validated lazily at first use and
+ # logged at ERROR by the updater's exception handler.
+ # fact_extraction is dormant (not wired to any runtime caller); excluded.
+ if self._config.prompts_dir is not None:
+ _dummy_vars = {
+ "current_memory": "{}",
+ "conversation": "(validation)",
+ "correction_hint": "",
+ "staleness_review_section": "",
+ "consolidation_section": "",
+ }
+ load_prompt("staleness_review", prompts_dir=self._config.prompts_dir).format(stale_facts="")
+ load_prompt("consolidation", prompts_dir=self._config.prompts_dir).format(consolidation_groups="", max_groups=1)
+ load_prompt_messages("memory_update", _dummy_vars, prompts_dir=self._config.prompts_dir)
self._queue = MemoryUpdateQueue(self._config, self._updater)
# ── Write ────────────────────────────────────────────────────────────
@@ -127,8 +150,7 @@ class DeerMem(MemoryManager):
Returns ``(filtered, correction_detected, reinforcement_detected)``
or ``None`` when there is no meaningful conversation (missing a user
- or an assistant turn). Identical logic to the pre-abstraction
- middleware/hook so behaviour is unchanged.
+ or an assistant turn).
"""
filtered = filter_messages_for_memory(
messages,
@@ -138,8 +160,8 @@ class DeerMem(MemoryManager):
assistant_messages = [m for m in filtered if getattr(m, "type", None) == "ai"]
if not user_messages or not assistant_messages:
return None
- correction_detected = detect_correction(filtered)
- reinforcement_detected = not correction_detected and detect_reinforcement(filtered)
+ correction_detected = detect_correction(filtered, patterns=self._correction_patterns)
+ reinforcement_detected = not correction_detected and detect_reinforcement(filtered, patterns=self._reinforcement_patterns)
return filtered, correction_detected, reinforcement_detected
# ── Read ─────────────────────────────────────────────────────────────
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/config.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/config.py
index 6e54a4493..136396cf8 100644
--- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/config.py
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/config.py
@@ -179,6 +179,15 @@ class DeerMemConfig(BaseModel):
le=20,
description=("Maximum number of source facts per consolidation group. Prevents the LLM from merging too many facts into one and losing important details."),
)
+ # ── Message processing (externalized patterns / prompts) ──
+ patterns_dir: str | None = Field(
+ default=None,
+ description=("Directory with correction.yaml / reinforcement.yaml overriding the bundled signal-detection patterns. None (default) = bundled core/message_patterns/. When set explicitly, both files must exist."),
+ )
+ prompts_dir: str | None = Field(
+ default=None,
+ description=("Directory with custom memory-extraction prompt templates (memory_update.chat.yaml, staleness_review.yaml, consolidation.yaml, fact_extraction.yaml). None (default) = bundled core/prompts/."),
+ )
# ── LLM (step 13: structured model sub-config consumed by core/llm.py build_llm) ──
model: DeerMemModelConfig = Field(
default_factory=DeerMemModelConfig,
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/correction.yaml b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/correction.yaml
new file mode 100644
index 000000000..384b66ac8
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/correction.yaml
@@ -0,0 +1,26 @@
+# Correction signal patterns for detect_correction (message_processing).
+#
+# Each list entry is either:
+# - a string -> compiled with no flags
+# - a mapping {pattern, flags} -> flags is a list of names: "ignorecase"
+#
+# In single-quoted strings backslashes are literal (regex \b stays \b); double
+# '' for a literal single quote. Default content mirrors the pre-externalization
+# hardcoded _CORRECTION_PATTERNS (en+zh) so zero-config behavior is unchanged.
+# Add languages / business phrases here without touching code.
+
+- pattern: '\bthat(?:''s| is) (?:wrong|incorrect)\b'
+ flags: [ignorecase]
+- pattern: '\byou misunderstood\b'
+ flags: [ignorecase]
+- pattern: '\btry again\b'
+ flags: [ignorecase]
+- pattern: '\bredo\b'
+ flags: [ignorecase]
+- '不对'
+- '你理解错了'
+- '你理解有误'
+- '重试'
+- '重新来'
+- '换一种'
+- '改用'
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/reinforcement.yaml b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/reinforcement.yaml
new file mode 100644
index 000000000..0c7bb3108
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/reinforcement.yaml
@@ -0,0 +1,36 @@
+# Reinforcement signal patterns for detect_reinforcement (message_processing).
+#
+# Each list entry is either:
+# - a string -> compiled with no flags
+# - a mapping {pattern, flags} -> flags is a list of names: "ignorecase"
+#
+# In single-quoted strings backslashes are literal (regex \b stays \b); double
+# '' for a literal single quote. Default content mirrors the pre-externalization
+# hardcoded _REINFORCEMENT_PATTERNS (en+zh) so zero-config behavior is unchanged.
+# Add languages / business phrases here without touching code.
+#
+# Note: bare confirmations like 你说的对 / 没错 / 对的 are intentionally NOT in the
+# defaults (对 appears in 对不起/对方/对啊 and would false-positive). Extend this
+# file if you need broader Chinese confirmation coverage.
+
+- pattern: '\byes[,.]?\s+(?:exactly|perfect|that(?:''s| is) (?:right|correct|it))\b'
+ flags: [ignorecase]
+- pattern: '\bperfect(?:[.!?]|$)'
+ flags: [ignorecase]
+- pattern: '\bexactly\s+(?:right|correct)\b'
+ flags: [ignorecase]
+- pattern: '\bthat(?:''s| is)\s+(?:exactly\s+)?(?:right|correct|what i (?:wanted|needed|meant))\b'
+ flags: [ignorecase]
+- pattern: '\bkeep\s+(?:doing\s+)?that\b'
+ flags: [ignorecase]
+- pattern: '\bjust\s+(?:like\s+)?(?:that|this)\b'
+ flags: [ignorecase]
+- pattern: '\bthis is (?:great|helpful)\b(?:[.!?]|$)'
+ flags: [ignorecase]
+- pattern: '\bthis is what i wanted\b(?:[.!?]|$)'
+ flags: [ignorecase]
+- '对[,,]?\s*就是这样(?:[。!?!?.]|$)'
+- '完全正确(?:[。!?!?.]|$)'
+- '(?:对[,,]?\s*)?就是这个意思(?:[。!?!?.]|$)'
+- '正是我想要的(?:[。!?!?.]|$)'
+- '继续保持(?:[。!?!?.]|$)'
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_processing.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_processing.py
index 77a32dfbd..46918d201 100644
--- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_processing.py
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_processing.py
@@ -2,40 +2,96 @@
from __future__ import annotations
+import logging
import re
from collections.abc import Mapping
from copy import copy
+from pathlib import Path
from typing import Any
+import yaml
+
+logger = logging.getLogger(__name__)
+
_UPLOAD_BLOCK_RE = re.compile(r"[\s\S]*?\n*", re.IGNORECASE)
-_CORRECTION_PATTERNS = (
- re.compile(r"\bthat(?:'s| is) (?:wrong|incorrect)\b", re.IGNORECASE),
- re.compile(r"\byou misunderstood\b", re.IGNORECASE),
- re.compile(r"\btry again\b", re.IGNORECASE),
- re.compile(r"\bredo\b", re.IGNORECASE),
- re.compile(r"不对"),
- re.compile(r"你理解错了"),
- re.compile(r"你理解有误"),
- re.compile(r"重试"),
- re.compile(r"重新来"),
- re.compile(r"换一种"),
- re.compile(r"改用"),
-)
-_REINFORCEMENT_PATTERNS = (
- re.compile(r"\byes[,.]?\s+(?:exactly|perfect|that(?:'s| is) (?:right|correct|it))\b", re.IGNORECASE),
- re.compile(r"\bperfect(?:[.!?]|$)", re.IGNORECASE),
- re.compile(r"\bexactly\s+(?:right|correct)\b", re.IGNORECASE),
- re.compile(r"\bthat(?:'s| is)\s+(?:exactly\s+)?(?:right|correct|what i (?:wanted|needed|meant))\b", re.IGNORECASE),
- re.compile(r"\bkeep\s+(?:doing\s+)?that\b", re.IGNORECASE),
- re.compile(r"\bjust\s+(?:like\s+)?(?:that|this)\b", re.IGNORECASE),
- re.compile(r"\bthis is (?:great|helpful)\b(?:[.!?]|$)", re.IGNORECASE),
- re.compile(r"\bthis is what i wanted\b(?:[.!?]|$)", re.IGNORECASE),
- re.compile(r"对[,,]?\s*就是这样(?:[。!?!?.]|$)"),
- re.compile(r"完全正确(?:[。!?!?.]|$)"),
- re.compile(r"(?:对[,,]?\s*)?就是这个意思(?:[。!?!?.]|$)"),
- re.compile(r"正是我想要的(?:[。!?!?.]|$)"),
- re.compile(r"继续保持(?:[。!?!?.]|$)"),
-)
+
+_PATTERN_CACHE: dict[tuple[str, str | None], list[re.Pattern[str]]] = {}
+
+
+def load_patterns(name: str, *, patterns_dir: str | None = None) -> list[re.Pattern[str]]:
+ """Load and compile signal patterns from a YAML file.
+
+ ``name`` is ``"correction"`` or ``"reinforcement"``. ``patterns_dir``
+ overrides the bundled ``core/message_patterns/`` directory; ``None`` (the
+ default) loads the bundled defaults, which mirror the pre-externalization
+ hardcoded patterns so zero-config behavior is unchanged. Compiled patterns
+ are cached per ``(name, patterns_dir)``.
+
+ Each YAML list entry is either a string (compiled with no flags) or a
+ mapping ``{pattern: , flags: [...]}`` where ``flags`` may contain
+ ``"ignorecase"``. Raises ``ValueError`` for invalid YAML, a non-list
+ top-level value, or an invalid regex (all with the file path in the message).
+ For explicit *patterns_dir*: missing files raise ``FileNotFoundError``;
+ unreadable files (OSError) are re-raised. Malformed entries and unknown flag
+ names are skipped with a WARNING. For bundled defaults (*patterns_dir* is
+ ``None``): missing/unreadable files log a WARNING and return ``[]``
+ (packaging bug, not a configuration error).
+ """
+ cache_key = (name, patterns_dir)
+ cached = _PATTERN_CACHE.get(cache_key)
+ if cached is not None:
+ return cached
+
+ base = Path(patterns_dir) if patterns_dir else Path(__file__).parent / "message_patterns"
+ path = base / f"{name}.yaml"
+ if not path.exists():
+ if patterns_dir is not None:
+ raise FileNotFoundError(f"Signal patterns file not found: {path}")
+ logger.warning("Signal patterns file not found (%s); %s detection disabled.", path, name)
+ _PATTERN_CACHE[cache_key] = []
+ return []
+
+ try:
+ with path.open(encoding="utf-8") as f:
+ data = yaml.safe_load(f) or []
+ except yaml.YAMLError as e:
+ raise ValueError(f"Invalid YAML in {path}: {e}") from e
+ except OSError as e:
+ if patterns_dir is not None:
+ raise OSError(f"Failed to read signal patterns file {path}: {e}") from e
+ logger.warning("Failed to read signal patterns %s: %s; %s detection disabled.", path, e, name)
+ _PATTERN_CACHE[cache_key] = []
+ return []
+
+ if not isinstance(data, list):
+ raise ValueError(f"Signal patterns file {path} must contain a list, not {type(data).__name__}")
+
+ compiled: list[re.Pattern[str]] = []
+ for i, entry in enumerate(data):
+ if isinstance(entry, str):
+ pattern_text, flag_names = entry, []
+ elif isinstance(entry, Mapping):
+ pattern_text = entry.get("pattern")
+ flag_names = entry.get("flags", []) or []
+ else:
+ logger.warning("Skipping non-string/non-mapping entry %d in %s (type %s)", i, path, type(entry).__name__)
+ continue
+ if not isinstance(pattern_text, str) or not pattern_text:
+ logger.warning("Skipping entry %d in %s: missing or empty 'pattern'", i, path)
+ continue
+ flags = 0
+ for flag_name in flag_names:
+ if flag_name == "ignorecase":
+ flags |= re.IGNORECASE
+ else:
+ logger.warning("Ignoring unknown flag %r in entry %d of %s", flag_name, i, path)
+ try:
+ compiled.append(re.compile(pattern_text, flags))
+ except re.error as e:
+ raise ValueError(f"Invalid regex in {path} entry {i}: {e} (pattern={pattern_text!r})") from e
+
+ _PATTERN_CACHE[cache_key] = compiled
+ return compiled
def extract_message_text(message: Any) -> str:
@@ -153,25 +209,40 @@ def filter_messages_for_memory(messages: list[Any], *, should_keep_hidden_messag
return filtered
-def detect_correction(messages: list[Any]) -> bool:
- """Detect explicit user corrections in recent conversation turns."""
+def detect_correction(messages: list[Any], *, patterns: list[re.Pattern[str]] | None = None) -> bool:
+ """Detect explicit user corrections in recent conversation turns.
+
+ ``patterns`` overrides the loaded patterns (useful when the caller has
+ already resolved ``DeerMemConfig.patterns_dir``); ``None`` loads the bundled
+ defaults via :func:`load_patterns`. The scan window stays ``messages[-6:]``
+ (the most recent human turns).
+ """
+ if patterns is None:
+ patterns = load_patterns("correction")
recent_user_msgs = [msg for msg in messages[-6:] if getattr(msg, "type", None) == "human"]
for msg in recent_user_msgs:
content = extract_message_text(msg).strip()
- if content and any(pattern.search(content) for pattern in _CORRECTION_PATTERNS):
+ if content and any(pattern.search(content) for pattern in patterns):
return True
return False
-def detect_reinforcement(messages: list[Any]) -> bool:
- """Detect explicit positive reinforcement signals in recent conversation turns."""
+def detect_reinforcement(messages: list[Any], *, patterns: list[re.Pattern[str]] | None = None) -> bool:
+ """Detect explicit positive reinforcement signals in recent conversation turns.
+
+ ``patterns`` overrides the loaded patterns (useful when the caller has
+ already resolved ``DeerMemConfig.patterns_dir``); ``None`` loads the bundled
+ defaults via :func:`load_patterns`. The scan window stays ``messages[-6:]``.
+ """
+ if patterns is None:
+ patterns = load_patterns("reinforcement")
recent_user_msgs = [msg for msg in messages[-6:] if getattr(msg, "type", None) == "human"]
for msg in recent_user_msgs:
content = extract_message_text(msg).strip()
- if content and any(pattern.search(content) for pattern in _REINFORCEMENT_PATTERNS):
+ if content and any(pattern.search(content) for pattern in patterns):
return True
return False
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py
index d9dd14271..2edecbee3 100644
--- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompt.py
@@ -8,10 +8,22 @@ import math
import re
import threading
import time
+from pathlib import Path
from typing import Any, cast
+import yaml
+from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
+
logger = logging.getLogger(__name__)
+
+class PromptConfigurationError(ValueError):
+ """A prompt-template configuration error (bad yaml, missing key, invalid
+ placeholder). Raised by :func:`load_prompt` and :func:`load_prompt_messages`
+ instead of a bare :class:`ValueError` so callers can distinguish permanent
+ configuration failures from recoverable runtime errors."""
+
+
try:
import tiktoken
@@ -19,241 +31,160 @@ try:
except ImportError:
TIKTOKEN_AVAILABLE = False
-# Prompt template for updating memory based on conversation
-MEMORY_UPDATE_PROMPT = """You are a memory management system. Your task is to analyze a conversation and update the user's memory profile.
+# ── Externalized prompt templates ───────────────────────────────────────
+#
+# The four memory prompts live as yaml files under ``core/prompts/`` (loaded by
+# :func:`load_prompt`) so they can be overridden per-agent or from an external
+# dir without code changes. The bundled defaults are byte-identical to the former
+# module-level constants, so zero-config behaviour is unchanged. Templates use
+# ``.format`` syntax (``{var}`` substitution, ``{{``/``}}`` for literal braces);
+# html-escaping stays at the assembly layer (``_escape_memory_for_prompt`` in
+# updater.py / ``format_conversation_for_update`` here), never inside the
+# template strings, so values are not double-escaped.
-Current Memory State:
-
-{current_memory}
-
+_PROMPTS_DEFAULT_DIR = Path(__file__).resolve().parent / "prompts"
-New Conversation to Process:
-
-{conversation}
-
+# Cache for load_prompt: repeated calls with the same (name, agent, dir) return
+# the cached template string without re-reading the yaml file. The shim constants
+# below also populate this cache at import time for the bundled defaults.
+_PROMPT_CACHE: dict[tuple[str, str | None, str | None], str] = {}
-Instructions:
-1. Analyze the conversation for important information about the user
-2. Extract relevant facts, preferences, and context with specific details (numbers, names, technologies)
-3. Update the memory sections as needed following the detailed length guidelines below
-
-Before extracting facts, perform a structured reflection on the conversation:
-1. Error/Retry Detection: Did the agent encounter errors, require retries, or produce incorrect results?
- If yes, record the root cause and correct approach as a high-confidence fact with category "correction".
-2. User Correction Detection: Did the user correct the agent's direction, understanding, or output?
- If yes, record the correct interpretation or approach as a high-confidence fact with category "correction".
- Include what went wrong in "sourceError" only when category is "correction" and the mistake is explicit in the conversation.
-3. Project Constraint Discovery: Were any project-specific constraints discovered during the conversation?
- If yes, record them as facts with the most appropriate category and confidence.
-
-{correction_hint}
-
-Memory Section Guidelines:
-
-**User Context** (Current state - concise summaries):
-- workContext: Professional role, company, key projects, main technologies (2-3 sentences)
- Example: Core contributor, project names with metrics (16k+ stars), technical stack
-- personalContext: Languages, communication preferences, key interests (1-2 sentences)
- Example: Bilingual capabilities, specific interest areas, expertise domains
-- topOfMind: Multiple ongoing focus areas and priorities (3-5 sentences, detailed paragraph)
- Example: Primary project work, parallel technical investigations, ongoing learning/tracking
- Include: Active implementation work, troubleshooting issues, market/research interests
- Note: This captures SEVERAL concurrent focus areas, not just one task
-
-**History** (Temporal context - rich paragraphs):
-- recentMonths: Detailed summary of recent activities (4-6 sentences or 1-2 paragraphs)
- Timeline: Last 1-3 months of interactions
- Include: Technologies explored, projects worked on, problems solved, interests demonstrated
-- earlierContext: Important historical patterns (3-5 sentences or 1 paragraph)
- Timeline: 3-12 months ago
- Include: Past projects, learning journeys, established patterns
-- longTermBackground: Persistent background and foundational context (2-4 sentences)
- Timeline: Overall/foundational information
- Include: Core expertise, longstanding interests, fundamental working style
-
-**Facts Extraction**:
-- Extract specific, quantifiable details (e.g., "16k+ GitHub stars", "200+ datasets")
-- Include proper nouns (company names, project names, technology names)
-- Preserve technical terminology and version numbers
-- Categories:
- * preference: Tools, styles, approaches user prefers/dislikes
- * knowledge: Specific expertise, technologies mastered, domain knowledge
- * context: Background facts (job title, projects, locations, languages)
- * behavior: Working patterns, communication habits, problem-solving approaches
- * goal: Stated objectives, learning targets, project ambitions
- * correction: Explicit agent mistakes or user corrections, including the correct approach
-- Fact lifetime (``expected_valid_days``, optional integer):
- How many days before this fact should be reviewed for possible removal.
- The system schedules review automatically; omit when uncertain.
- * <= 14: highly transient - active bugs, immediate tasks, today's focus
- * 15-60: short-term - current experiments, in-progress side projects, near-term goals
- * 60-180: medium-term - current role, active tech stack, ongoing preferences
- * 180-365: stable - professional background, established working patterns
- * > 365: very stable - core skills, native language, personality traits
- Assign the value that semantically fits; values above the server-configured
- ceiling are silently reduced on storage.
-- Confidence levels:
- * 0.9-1.0: Explicitly stated facts ("I work on X", "My role is Y")
- * 0.7-0.8: Strongly implied from actions/discussions
- * 0.5-0.6: Inferred patterns (use sparingly, only for clear patterns)
-
-**What Goes Where**:
-- workContext: Current job, active projects, primary tech stack
-- personalContext: Languages, personality, interests outside direct work tasks
-- topOfMind: Multiple ongoing priorities and focus areas user cares about recently (gets updated most frequently)
- Should capture 3-5 concurrent themes: main work, side explorations, learning/tracking interests
-- recentMonths: Detailed account of recent technical explorations and work
-- earlierContext: Patterns from slightly older interactions still relevant
-- longTermBackground: Unchanging foundational facts about the user
-
-**Multilingual Content**:
-- Preserve original language for proper nouns and company names
-- Keep technical terms in their original form (DeepSeek, LangGraph, etc.)
-- Note language capabilities in personalContext
-
-Output Format (JSON):
-{{
- "user": {{
- "workContext": {{ "summary": "...", "shouldUpdate": true/false }},
- "personalContext": {{ "summary": "...", "shouldUpdate": true/false }},
- "topOfMind": {{ "summary": "...", "shouldUpdate": true/false }}
- }},
- "history": {{
- "recentMonths": {{ "summary": "...", "shouldUpdate": true/false }},
- "earlierContext": {{ "summary": "...", "shouldUpdate": true/false }},
- "longTermBackground": {{ "summary": "...", "shouldUpdate": true/false }}
- }},
- "newFacts": [
- {{ "content": "...", "category": "preference|knowledge|context|behavior|goal|correction", "confidence": 0.0-1.0, "expected_valid_days": 90 }}
- ],
- "factsToRemove": ["fact_id_1", "fact_id_2"],
- "staleFactsToRemove": [{{ "id": "fact_id", "reason": "brief explanation" }}],
- "staleFactsToExtend": [{{ "id": "fact_id", "extend_by_days": 365, "reason": "brief explanation" }}],
- "factsToConsolidate": [
- {{
- "sourceIds": ["fact_id_1", "fact_id_2"],
- "consolidated": {{ "content": "synthesized fact", "category": "knowledge", "confidence": 0.9 }}
- }}
- ]
-}}
-
-Important Rules:
-- Only set shouldUpdate=true if there's meaningful new information
-- Follow length guidelines: workContext/personalContext are concise (1-3 sentences), topOfMind and history sections are detailed (paragraphs)
-- Include specific metrics, version numbers, and proper nouns in facts
-- Only add facts that are clearly stated (0.9+) or strongly implied (0.7+)
-- Use category "correction" for explicit agent mistakes or user corrections; assign confidence >= 0.95 when the correction is explicit
-- Include "sourceError" only for explicit correction facts when the prior mistake or wrong approach is clearly stated; omit it otherwise
-- Remove facts that are contradicted by new information
-- When updating topOfMind, integrate new focus areas while removing completed/abandoned ones
- Keep 3-5 concurrent focus themes that are still active and relevant
-- For history sections, integrate new information chronologically into appropriate time period
-- Preserve technical accuracy - keep exact names of technologies, companies, projects
-- Focus on information useful for future interactions and personalization
-- IMPORTANT: Do NOT record file upload events in memory. Uploaded files are
- session-specific and ephemeral — they will not be accessible in future sessions.
- Recording upload events causes confusion in subsequent conversations.
-
-{staleness_review_section}
-
-{consolidation_section}
-
-Return ONLY valid JSON, no explanation or markdown."""
+# Cache for load_prompt_messages: stores parsed raw templates (list of {role,
+# content} dicts) keyed by (name, agent, dir). On a cache hit the templates are
+# rendered with the caller's variables; the file is only read once per key.
+_CHAT_TEMPLATE_CACHE: dict[tuple[str, str | None, str | None], tuple[list[dict[str, str]], str]] = {}
-# Prompt section injected into MEMORY_UPDATE_PROMPT when staleness review triggers.
-# Surfaces aged facts explicitly so the LLM can semantically judge each one,
-# rather than relying on passive contradiction from the current conversation.
-STALENESS_REVIEW_PROMPT = """## Staleness Review
-
-The following facts have reached their individual review window and may no longer
-accurately reflect the user's current situation. Each entry shows a ``valid:Nd``
-annotation - the number of days this fact was expected to remain valid before
-re-evaluation. Use it to calibrate conservatism: a ``valid:30d`` fact was
-considered volatile at creation; a ``valid:365d`` fact was considered stable.
-
-
-{stale_facts}
-
-
-For each fact, decide KEEP, REMOVE, or EXTEND:
-- KEEP: Still likely valid - even if not mentioned in this conversation.
- Stable attributes (native language, core expertise, personality traits) often
- remain true indefinitely.
-- REMOVE: Outdated, contradicted by recent context, or no longer relevant.
- Examples: tech-stack migrations, job changes, relocated offices, abandoned projects.
-- EXTEND: Keep but recalibrate the review window (see below).
-
-Add REMOVE decisions to "staleFactsToRemove" in your output JSON.
-Each entry must be {{"id": "fact_id", "reason": "brief explanation"}}.
-The reason should cite what signal in the conversation (or absence thereof)
-supports the removal.
-
-Optionally, for facts you KEEP and wish to recalibrate, add them to
-"staleFactsToExtend" with the number of days from now before the next review:
-{{"id": "fact_id", "extend_by_days": 365, "reason": "brief explanation"}}
-Use this when the current window seems miscalibrated - e.g. a core skill marked
-``valid:30d`` that is clearly stable, or a goal nearing completion whose window
-should shrink. Omit facts whose current window already seems appropriate.
-
-Be conservative - when in doubt, KEEP. Removing a valid fact is worse than
-keeping a slightly stale one, because the next review cycle will re-evaluate it."""
+def _render_messages(
+ raw_templates: list[dict[str, str]],
+ variables: dict[str, Any],
+ source_path: str,
+) -> list[BaseMessage]:
+ """Render cached chat templates with fresh *variables*."""
+ messages: list[BaseMessage] = []
+ for tmpl in raw_templates:
+ content = tmpl["content"]
+ try:
+ content = content.format(**variables)
+ except (KeyError, ValueError) as e:
+ raise PromptConfigurationError(f"Invalid placeholder in {source_path!r} (content of role={tmpl['role']!r}): {e}") from e
+ if tmpl["role"] == "system":
+ messages.append(SystemMessage(content=content))
+ else:
+ messages.append(HumanMessage(content=content))
+ return messages
-# Prompt section injected into MEMORY_UPDATE_PROMPT when consolidation triggers.
-# Surfaces fact groups that have accumulated many entries in the same category
-# so the LLM can synthesize them into fewer, richer facts.
-CONSOLIDATION_PROMPT = """## Memory Consolidation
+def load_prompt(
+ name: str,
+ *,
+ agent_name: str | None = None,
+ prompts_dir: str | None = None,
+) -> str:
+ """Load a prompt template by name (agent override > default).
-The following fact categories have accumulated many individual entries.
-Review each group and identify facts that can be synthesized into a single,
-richer consolidated fact that preserves all key information.
+ Reads ``{prompts_dir}/{agent_name}/{name}.yaml`` if present, else
+ ``{prompts_dir}/{name}.yaml``. ``prompts_dir`` defaults to the package's
+ bundled ``core/prompts/``. Returns the raw ``template`` string (``.format``
+ syntax); the caller renders it with ``.format(**vars)``.
-{consolidation_groups}
+ Results are cached per ``(name, agent_name, prompts_dir)`` so the filesystem
+ read happens at most once per combination (typically once per process for
+ the bundled defaults).
+ """
+ cache_key = (name, agent_name, prompts_dir)
+ cached = _PROMPT_CACHE.get(cache_key)
+ if cached is not None:
+ return cached
-For each group, decide:
-- CONSOLIDATE: Multiple facts can be merged into one richer fact.
- Specify the source fact IDs and the consolidated content.
-- SKIP: Facts are distinct enough to remain separate.
-
-Add consolidation decisions to "factsToConsolidate" in your output JSON.
-Each entry: {{"sourceIds": ["fact_id_1", "fact_id_2"], "consolidated": {{"content": "...", "category": "...", "confidence": 0.9}}}}
-
-Rules:
-- The consolidated fact must preserve ALL key details from source facts
-- Only consolidate facts that describe the same aspect of the user
-- Confidence of consolidated fact = max of source confidences
-- Be conservative - when in doubt, keep facts separate
-- Maximum {max_groups} consolidation groups per cycle"""
+ base = Path(prompts_dir) if prompts_dir else _PROMPTS_DEFAULT_DIR
+ candidates: list[Path] = [base / f"{name}.yaml"]
+ if agent_name:
+ candidates.insert(0, base / agent_name / f"{name}.yaml")
+ for path in candidates:
+ if path.is_file():
+ try:
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
+ except yaml.YAMLError as e:
+ raise PromptConfigurationError(f"Invalid YAML in {path}: {e}") from e
+ data = data or {}
+ fmt = data.get("format", "text")
+ if fmt != "text":
+ raise PromptConfigurationError(f"Expected format='text' in {path}, got {fmt!r}; use load_prompt_messages() for chat-format templates")
+ template = data.get("template")
+ if not isinstance(template, str) or not template:
+ raise PromptConfigurationError(f"Missing or empty 'template' key in {path}")
+ _PROMPT_CACHE[cache_key] = template
+ return template
+ searched = ", ".join(str(c) for c in candidates)
+ raise FileNotFoundError(f"prompt template not found: {name} (searched: {searched})")
-# Prompt template for extracting facts from a single message
-FACT_EXTRACTION_PROMPT = """Extract factual information about the user from this message.
+def load_prompt_messages(
+ name: str,
+ variables: dict[str, Any],
+ *,
+ agent_name: str | None = None,
+ prompts_dir: str | None = None,
+) -> list[BaseMessage]:
+ """Load + render a chat-form prompt template. Returns ``list[BaseMessage]``.
-Message:
-{message}
+ Reads ``{prompts_dir}/{agent_name}/{name}.chat.yaml`` if present, else
+ ``{prompts_dir}/{name}.chat.yaml``. Each message's ``content`` is rendered
+ with ``.format(**variables)``. The system content has no variables (only
+ literal ``{{ }}`` JSON braces), so it renders byte-identical every call --
+ prefix-cache friendly, mirroring the lead agent's static system prompt.
-Extract facts in this JSON format:
-{{
- "facts": [
- {{ "content": "...", "category": "preference|knowledge|context|behavior|goal|correction", "confidence": 0.0-1.0 }}
- ]
-}}
+ The raw templates (role + content before substitution) are cached per
+ ``(name, agent, prompts_dir)`` so the yaml file is only read once; only
+ per-call rendering runs on each invocation.
-Categories:
-- preference: User preferences (likes/dislikes, styles, tools)
-- knowledge: User's expertise or knowledge areas
-- context: Background context (location, job, projects)
-- behavior: Behavioral patterns
-- goal: User's goals or objectives
-- correction: Explicit corrections or mistakes to avoid repeating
+ For the text form (single string), use :func:`load_prompt` instead.
+ """
+ cache_key = (name, agent_name, prompts_dir)
+ cached_chat = _CHAT_TEMPLATE_CACHE.get(cache_key)
+ if cached_chat is not None:
+ raw_templates, source_path = cached_chat
+ return _render_messages(raw_templates, variables, source_path)
-Rules:
-- Only extract clear, specific facts
-- Confidence should reflect certainty (explicit statement = 0.9+, implied = 0.6-0.8)
-- Skip vague or temporary information
+ base = Path(prompts_dir) if prompts_dir else _PROMPTS_DEFAULT_DIR
+ candidates: list[Path] = [base / f"{name}.chat.yaml"]
+ if agent_name:
+ candidates.insert(0, base / agent_name / f"{name}.chat.yaml")
+ for path in candidates:
+ if path.is_file():
+ try:
+ data = yaml.safe_load(path.read_text(encoding="utf-8"))
+ except yaml.YAMLError as e:
+ raise PromptConfigurationError(f"Invalid YAML in {path}: {e}") from e
+ data = data or {}
+ fmt = data.get("format", "chat")
+ if fmt != "chat":
+ raise PromptConfigurationError(f"Expected format='chat' in {path}, got {fmt!r}; use load_prompt() for text-format templates")
+ msg_list = data.get("messages")
+ if not isinstance(msg_list, list) or not msg_list:
+ raise PromptConfigurationError(f"Missing or empty 'messages' key in {path}")
+ raw_templates: list[dict[str, str]] = []
+ for msg in msg_list:
+ role = msg.get("role", "user")
+ content = msg.get("content", "")
+ if not isinstance(content, str):
+ content = str(content)
+ raw_templates.append({"role": role, "content": content})
+ _CHAT_TEMPLATE_CACHE[cache_key] = (raw_templates, str(path))
+ return _render_messages(raw_templates, variables, str(path))
+ searched = ", ".join(str(c) for c in candidates)
+ raise FileNotFoundError(f"chat prompt template not found: {name} (searched: {searched})")
-Return ONLY valid JSON."""
+
+# Module-level aliases for the injected text sections (staleness_review /
+# consolidation / fact_extraction). Each loads its bundled yaml template once
+# at import. ``memory_update`` is NOT here -- it uses the chat form via
+# :func:`load_prompt_messages` (system/user split, mirroring the lead agent's
+# static system prompt).
+STALENESS_REVIEW_PROMPT = load_prompt("staleness_review")
+CONSOLIDATION_PROMPT = load_prompt("consolidation")
+FACT_EXTRACTION_PROMPT = load_prompt("fact_extraction")
# Module-level tiktoken encoding cache. Populated lazily on first use;
@@ -424,7 +355,7 @@ def _format_fact_line(fact: dict[str, Any]) -> str | None:
# rendered into the block of the lead-agent system prompt. Escape
# them so a value like "" cannot close the block
# and relocate the text after it out of the user-managed trust zone the
- # prompt declares. Mirrors the MEMORY_UPDATE_PROMPT escaping in #4028/#4060.
+ # prompt declares. Mirrors the memory_update prompt escaping in #4028/#4060.
# quote=False: these land in element-text position (never attribute values),
# so only <, >, & can break out - leave ' and " in facts untouched.
content = html.escape(content, quote=False)
@@ -836,7 +767,7 @@ def format_conversation_for_update(messages: list[Any]) -> str:
content = str(content)[:1000] + "..."
# Escape < > & before embedding into the block of
- # MEMORY_UPDATE_PROMPT. This raw user turn is the most attacker-influenced
+ # the memory_update prompt. This raw user turn is the most attacker-influenced
# input in the prompt, so an unescaped value like
# "..." would close the block and forge a
# authority section for the extraction LLM. Same block-
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/consolidation.yaml b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/consolidation.yaml
new file mode 100644
index 000000000..1f5825be3
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/consolidation.yaml
@@ -0,0 +1,25 @@
+format: text
+version: "1.0"
+template: |-
+ ## Memory Consolidation
+
+ The following fact categories have accumulated many individual entries.
+ Review each group and identify facts that can be synthesized into a single,
+ richer consolidated fact that preserves all key information.
+
+ {consolidation_groups}
+
+ For each group, decide:
+ - CONSOLIDATE: Multiple facts can be merged into one richer fact.
+ Specify the source fact IDs and the consolidated content.
+ - SKIP: Facts are distinct enough to remain separate.
+
+ Add consolidation decisions to "factsToConsolidate" in your output JSON.
+ Each entry: {{"sourceIds": ["fact_id_1", "fact_id_2"], "consolidated": {{"content": "...", "category": "...", "confidence": 0.9}}}}
+
+ Rules:
+ - The consolidated fact must preserve ALL key details from source facts
+ - Only consolidate facts that describe the same aspect of the user
+ - Confidence of consolidated fact = max of source confidences
+ - Be conservative - when in doubt, keep facts separate
+ - Maximum {max_groups} consolidation groups per cycle
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/fact_extraction.yaml b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/fact_extraction.yaml
new file mode 100644
index 000000000..6828e0934
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/fact_extraction.yaml
@@ -0,0 +1,29 @@
+format: text
+version: "1.0"
+template: |-
+ Extract factual information about the user from this message.
+
+ Message:
+ {message}
+
+ Extract facts in this JSON format:
+ {{
+ "facts": [
+ {{ "content": "...", "category": "preference|knowledge|context|behavior|goal|correction", "confidence": 0.0-1.0 }}
+ ]
+ }}
+
+ Categories:
+ - preference: User preferences (likes/dislikes, styles, tools)
+ - knowledge: User's expertise or knowledge areas
+ - context: Background context (location, job, projects)
+ - behavior: Behavioral patterns
+ - goal: User's goals or objectives
+ - correction: Explicit corrections or mistakes to avoid repeating
+
+ Rules:
+ - Only extract clear, specific facts
+ - Confidence should reflect certainty (explicit statement = 0.9+, implied = 0.6-0.8)
+ - Skip vague or temporary information
+
+ Return ONLY valid JSON.
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/memory_update.chat.yaml b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/memory_update.chat.yaml
new file mode 100644
index 000000000..c7cfac354
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/memory_update.chat.yaml
@@ -0,0 +1,147 @@
+format: chat
+version: "1.0"
+messages:
+ - role: system
+ content: |-
+ You are a memory management system. Your task is to analyze a conversation and update the user's memory profile.
+
+ Instructions:
+ 1. Analyze the conversation for important information about the user
+ 2. Extract relevant facts, preferences, and context with specific details (numbers, names, technologies)
+ 3. Update the memory sections as needed following the detailed length guidelines below
+
+ Before extracting facts, perform a structured reflection on the conversation:
+ 1. Error/Retry Detection: Did the agent encounter errors, require retries, or produce incorrect results?
+ If yes, record the root cause and correct approach as a high-confidence fact with category "correction".
+ 2. User Correction Detection: Did the user correct the agent's direction, understanding, or output?
+ If yes, record the correct interpretation or approach as a high-confidence fact with category "correction".
+ Include what went wrong in "sourceError" only when category is "correction" and the mistake is explicit in the conversation.
+ 3. Project Constraint Discovery: Were any project-specific constraints discovered during the conversation?
+ If yes, record them as facts with the most appropriate category and confidence.
+
+ Memory Section Guidelines:
+
+ **User Context** (Current state - concise summaries):
+ - workContext: Professional role, company, key projects, main technologies (2-3 sentences)
+ Example: Core contributor, project names with metrics (16k+ stars), technical stack
+ - personalContext: Languages, communication preferences, key interests (1-2 sentences)
+ Example: Bilingual capabilities, specific interest areas, expertise domains
+ - topOfMind: Multiple ongoing focus areas and priorities (3-5 sentences, detailed paragraph)
+ Example: Primary project work, parallel technical investigations, ongoing learning/tracking
+ Include: Active implementation work, troubleshooting issues, market/research interests
+ Note: This captures SEVERAL concurrent focus areas, not just one task
+
+ **History** (Temporal context - rich paragraphs):
+ - recentMonths: Detailed summary of recent activities (4-6 sentences or 1-2 paragraphs)
+ Timeline: Last 1-3 months of interactions
+ Include: Technologies explored, projects worked on, problems solved, interests demonstrated
+ - earlierContext: Important historical patterns (3-5 sentences or 1 paragraph)
+ Timeline: 3-12 months ago
+ Include: Past projects, learning journeys, established patterns
+ - longTermBackground: Persistent background and foundational context (2-4 sentences)
+ Timeline: Overall/foundational information
+ Include: Core expertise, longstanding interests, fundamental working style
+
+ **Facts Extraction**:
+ - Extract specific, quantifiable details (e.g., "16k+ GitHub stars", "200+ datasets")
+ - Include proper nouns (company names, project names, technology names)
+ - Preserve technical terminology and version numbers
+ - Categories:
+ * preference: Tools, styles, approaches user prefers/dislikes
+ * knowledge: Specific expertise, technologies mastered, domain knowledge
+ * context: Background facts (job title, projects, locations, languages)
+ * behavior: Working patterns, communication habits, problem-solving approaches
+ * goal: Stated objectives, learning targets, project ambitions
+ * correction: Explicit agent mistakes or user corrections, including the correct approach
+ - Fact lifetime (``expected_valid_days``, optional integer):
+ How many days before this fact should be reviewed for possible removal.
+ The system schedules review automatically; omit when uncertain.
+ * <= 14: highly transient - active bugs, immediate tasks, today's focus
+ * 15-60: short-term - current experiments, in-progress side projects, near-term goals
+ * 60-180: medium-term - current role, active tech stack, ongoing preferences
+ * 180-365: stable - professional background, established working patterns
+ * > 365: very stable - core skills, native language, personality traits
+ Assign the value that semantically fits; values above the server-configured
+ ceiling are silently reduced on storage.
+ - Confidence levels:
+ * 0.9-1.0: Explicitly stated facts ("I work on X", "My role is Y")
+ * 0.7-0.8: Strongly implied from actions/discussions
+ * 0.5-0.6: Inferred patterns (use sparingly, only for clear patterns)
+
+ **What Goes Where**:
+ - workContext: Current job, active projects, primary tech stack
+ - personalContext: Languages, personality, interests outside direct work tasks
+ - topOfMind: Multiple ongoing priorities and focus areas user cares about recently (gets updated most frequently)
+ Should capture 3-5 concurrent themes: main work, side explorations, learning/tracking interests
+ - recentMonths: Detailed account of recent technical explorations and work
+ - earlierContext: Patterns from slightly older interactions still relevant
+ - longTermBackground: Unchanging foundational facts about the user
+
+ **Multilingual Content**:
+ - Preserve original language for proper nouns and company names
+ - Keep technical terms in their original form (DeepSeek, LangGraph, etc.)
+ - Note language capabilities in personalContext
+
+ Output Format (JSON):
+ {{
+ "user": {{
+ "workContext": {{ "summary": "...", "shouldUpdate": true/false }},
+ "personalContext": {{ "summary": "...", "shouldUpdate": true/false }},
+ "topOfMind": {{ "summary": "...", "shouldUpdate": true/false }}
+ }},
+ "history": {{
+ "recentMonths": {{ "summary": "...", "shouldUpdate": true/false }},
+ "earlierContext": {{ "summary": "...", "shouldUpdate": true/false }},
+ "longTermBackground": {{ "summary": "...", "shouldUpdate": true/false }}
+ }},
+ "newFacts": [
+ {{ "content": "...", "category": "preference|knowledge|context|behavior|goal|correction", "confidence": 0.0-1.0, "expected_valid_days": 90 }}
+ ],
+ "factsToRemove": ["fact_id_1", "fact_id_2"],
+ "staleFactsToRemove": [{{ "id": "fact_id", "reason": "brief explanation" }}],
+ "staleFactsToExtend": [{{ "id": "fact_id", "extend_by_days": 365, "reason": "brief explanation" }}],
+ "factsToConsolidate": [
+ {{
+ "sourceIds": ["fact_id_1", "fact_id_2"],
+ "consolidated": {{ "content": "synthesized fact", "category": "knowledge", "confidence": 0.9 }}
+ }}
+ ]
+ }}
+
+ Important Rules:
+ - Only set shouldUpdate=true if there's meaningful new information
+ - Follow length guidelines: workContext/personalContext are concise (1-3 sentences), topOfMind and history sections are detailed (paragraphs)
+ - Include specific metrics, version numbers, and proper nouns in facts
+ - Only add facts that are clearly stated (0.9+) or strongly implied (0.7+)
+ - Use category "correction" for explicit agent mistakes or user corrections; assign confidence >= 0.95 when the correction is explicit
+ - Include "sourceError" only for explicit correction facts when the prior mistake or wrong approach is clearly stated; omit it otherwise
+ - Remove facts that are contradicted by new information
+ - When updating topOfMind, integrate new focus areas while removing completed/abandoned ones
+ Keep 3-5 concurrent focus themes that are still active and relevant
+ - For history sections, integrate new information chronologically into appropriate time period
+ - Preserve technical accuracy - keep exact names of technologies, companies, projects
+ - Focus on information useful for future interactions and personalization
+ - IMPORTANT: Do NOT record file upload events in memory. Uploaded files are
+ session-specific and ephemeral — they will not be accessible in future sessions.
+ Recording upload events causes confusion in subsequent conversations.
+
+ Return ONLY valid JSON, no explanation or markdown.
+ - role: user
+ content: |-
+ Current Memory State:
+
+ {current_memory}
+
+
+ New Conversation to Process:
+
+ {conversation}
+
+
+ {correction_hint}
+
+ {staleness_review_section}
+
+ {consolidation_section}
+
+ Update the memory based on the above conversation.
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/staleness_review.yaml b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/staleness_review.yaml
new file mode 100644
index 000000000..d3b3df400
--- /dev/null
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/staleness_review.yaml
@@ -0,0 +1,37 @@
+format: text
+version: "1.0"
+template: |-
+ ## Staleness Review
+
+ The following facts have reached their individual review window and may no longer
+ accurately reflect the user's current situation. Each entry shows a ``valid:Nd``
+ annotation - the number of days this fact was expected to remain valid before
+ re-evaluation. Use it to calibrate conservatism: a ``valid:30d`` fact was
+ considered volatile at creation; a ``valid:365d`` fact was considered stable.
+
+
+ {stale_facts}
+
+
+ For each fact, decide KEEP, REMOVE, or EXTEND:
+ - KEEP: Still likely valid - even if not mentioned in this conversation.
+ Stable attributes (native language, core expertise, personality traits) often
+ remain true indefinitely.
+ - REMOVE: Outdated, contradicted by recent context, or no longer relevant.
+ Examples: tech-stack migrations, job changes, relocated offices, abandoned projects.
+ - EXTEND: Keep but recalibrate the review window (see below).
+
+ Add REMOVE decisions to "staleFactsToRemove" in your output JSON.
+ Each entry must be {{"id": "fact_id", "reason": "brief explanation"}}.
+ The reason should cite what signal in the conversation (or absence thereof)
+ supports the removal.
+
+ Optionally, for facts you KEEP and wish to recalibrate, add them to
+ "staleFactsToExtend" with the number of days from now before the next review:
+ {{"id": "fact_id", "extend_by_days": 365, "reason": "brief explanation"}}
+ Use this when the current window seems miscalibrated - e.g. a core skill marked
+ ``valid:30d`` that is clearly stable, or a goal nearing completion whose window
+ should shrink. Omit facts whose current window already seems appropriate.
+
+ Be conservative - when in doubt, KEEP. Removing a valid fact is worse than
+ keeping a slightly stale one, because the next review cycle will re-evaluate it.
diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py
index 1e43c62d2..490832515 100644
--- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py
+++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py
@@ -15,10 +15,9 @@ from typing import Any
from ..config import DeerMemConfig
from .prompt import (
- CONSOLIDATION_PROMPT,
- MEMORY_UPDATE_PROMPT,
- STALENESS_REVIEW_PROMPT,
format_conversation_for_update,
+ load_prompt,
+ load_prompt_messages,
)
from .storage import (
MemoryStorage,
@@ -445,6 +444,9 @@ def _select_stale_candidates(
def _build_staleness_section(
stale_candidates: list[dict[str, Any]],
config: Any,
+ *,
+ prompts_dir: str | None = None,
+ agent_name: str | None = None,
) -> str:
"""Format the staleness review prompt section from candidate facts.
@@ -468,7 +470,7 @@ def _build_staleness_section(
content = html.escape(str(fact.get("content", "")), quote=False)
effective_age = _effective_fact_staleness_age(fact, config)
lines.append(f'- [{fid} | {cat} | {conf:.2f} | {created_short} | valid:{effective_age}d] "{content}"')
- return STALENESS_REVIEW_PROMPT.format(stale_facts="\n".join(lines))
+ return load_prompt("staleness_review", prompts_dir=prompts_dir, agent_name=agent_name).format(stale_facts="\n".join(lines))
# ── Consolidation helpers ───────────────────────────────────────────────
@@ -504,6 +506,9 @@ def _build_consolidation_section(
candidates: dict[str, list[dict[str, Any]]],
max_groups: int = 3,
max_sources: int = 8,
+ *,
+ prompts_dir: str | None = None,
+ agent_name: str | None = None,
) -> str:
"""Format consolidation candidate groups into the prompt section.
@@ -525,13 +530,13 @@ def _build_consolidation_section(
lines.append(f'- [{fid} | {conf:.2f}] "{content}"')
shown = min(len(group), max_sources)
parts.append(f'\n' + "\n".join(lines) + "\n")
- return CONSOLIDATION_PROMPT.format(consolidation_groups="\n\n".join(parts), max_groups=max_groups)
+ return load_prompt("consolidation", prompts_dir=prompts_dir, agent_name=agent_name).format(consolidation_groups="\n\n".join(parts), max_groups=max_groups)
def _escape_memory_for_prompt(memory: Any) -> Any:
"""Return a copy of ``memory`` with every string leaf HTML-escaped.
- ``MEMORY_UPDATE_PROMPT`` embeds the full memory state as a ``json.dumps``
+ The memory_update prompt embeds the full memory state as a ``json.dumps``
blob inside a ``...`` block. ``json.dumps``
escapes ``"`` and ``\\`` but leaves ``<``, ``>`` and ``&`` intact, so a
user-influenced field - e.g. a fact ``content`` of
@@ -559,7 +564,7 @@ def _escape_memory_for_prompt(memory: Any) -> Any:
class MemoryUpdater:
"""Updates memory using LLM based on conversation context."""
- def __init__(self, config: DeerMemConfig, storage: MemoryStorage, llm: Any = None):
+ def __init__(self, config: DeerMemConfig, storage: MemoryStorage, llm: Any = None, *, prompts_dir: str | None = None):
"""Initialize the memory updater with injected config + storage + llm (DI).
Args:
@@ -567,10 +572,13 @@ class MemoryUpdater:
storage: Memory storage instance (owned by DeerMem, injected here).
llm: The chat model for memory extraction (owned by DeerMem, injected
here). None when no LLM is configured; an update raises in that case.
+ prompts_dir: Optional custom prompt-template directory forwarded to
+ ``load_prompt`` / ``load_prompt_messages``. None = bundled defaults.
"""
self._config = config
self._storage = storage
self._llm = llm
+ self._prompts_dir = prompts_dir
# ── Data access + fact CRUD (formerly module-level functions; use self._storage) ──
@@ -717,7 +725,7 @@ class MemoryUpdater:
correction_detected: bool,
reinforcement_detected: bool,
user_id: str | None = None,
- ) -> tuple[dict[str, Any], str] | None:
+ ) -> tuple[dict[str, Any], list[Any]] | None:
"""Load memory and build the update prompt for a conversation."""
config = self._config
if not messages:
@@ -738,7 +746,7 @@ class MemoryUpdater:
if config.staleness_review_enabled:
stale_candidates = _select_stale_candidates(current_memory, config)
if len(stale_candidates) >= config.staleness_min_candidates:
- staleness_section = _build_staleness_section(stale_candidates, config)
+ staleness_section = _build_staleness_section(stale_candidates, config, prompts_dir=self._prompts_dir, agent_name=agent_name)
# ── Build consolidation section ──
consolidation_section = ""
@@ -749,15 +757,18 @@ class MemoryUpdater:
consolidation_candidates,
max_groups=config.consolidation_max_groups_per_cycle,
max_sources=config.consolidation_max_sources,
+ prompts_dir=self._prompts_dir,
+ agent_name=agent_name,
)
- prompt = MEMORY_UPDATE_PROMPT.format(
- current_memory=json.dumps(_escape_memory_for_prompt(current_memory), indent=2, ensure_ascii=False),
- conversation=conversation_text,
- correction_hint=correction_hint,
- staleness_review_section=staleness_section,
- consolidation_section=consolidation_section,
- )
+ variables = {
+ "current_memory": json.dumps(_escape_memory_for_prompt(current_memory), indent=2, ensure_ascii=False),
+ "conversation": conversation_text,
+ "correction_hint": correction_hint,
+ "staleness_review_section": staleness_section,
+ "consolidation_section": consolidation_section,
+ }
+ prompt = load_prompt_messages("memory_update", variables, agent_name=agent_name, prompts_dir=self._prompts_dir)
return current_memory, prompt
def _finalize_update(
diff --git a/backend/tests/test_memory_consolidation.py b/backend/tests/test_memory_consolidation.py
index 68c1e83d9..90915005a 100644
--- a/backend/tests/test_memory_consolidation.py
+++ b/backend/tests/test_memory_consolidation.py
@@ -956,7 +956,8 @@ class TestPrepareUpdatePromptConsolidation:
)
assert result is not None
- _, prompt = result
+ _, messages = result
+ prompt = "\n".join(m.content for m in messages)
assert "Memory Consolidation" in prompt
assert "consolidation_candidates" in prompt
@@ -980,7 +981,8 @@ class TestPrepareUpdatePromptConsolidation:
)
assert result is not None
- _, prompt = result
+ _, messages = result
+ prompt = "\n".join(m.content for m in messages)
assert "Memory Consolidation" not in prompt
def test_consolidation_section_omitted_when_disabled(self):
@@ -1003,7 +1005,8 @@ class TestPrepareUpdatePromptConsolidation:
)
assert result is not None
- _, prompt = result
+ _, messages = result
+ prompt = "\n".join(m.content for m in messages)
assert "Memory Consolidation" not in prompt
diff --git a/backend/tests/test_memory_staleness_review.py b/backend/tests/test_memory_staleness_review.py
index 330d3dc7a..72639a4f5 100644
--- a/backend/tests/test_memory_staleness_review.py
+++ b/backend/tests/test_memory_staleness_review.py
@@ -1019,7 +1019,8 @@ class TestPrepareUpdatePromptStaleness:
)
assert result is not None
- _, prompt = result
+ _, messages = result
+ prompt = "\n".join(m.content for m in messages)
assert "Staleness Review" in prompt
assert "" in prompt
@@ -1042,7 +1043,8 @@ class TestPrepareUpdatePromptStaleness:
)
assert result is not None
- _, prompt = result
+ _, messages = result
+ prompt = "\n".join(m.content for m in messages)
assert "Staleness Review" not in prompt
assert "" not in prompt
@@ -1066,7 +1068,8 @@ class TestPrepareUpdatePromptStaleness:
)
assert result is not None
- _, prompt = result
+ _, messages = result
+ prompt = "\n".join(m.content for m in messages)
assert "Staleness Review" not in prompt
def test_staleness_section_shows_valid_annotation(self):
@@ -1090,4 +1093,6 @@ class TestPrepareUpdatePromptStaleness:
assert result is not None
_, prompt = result
- assert "valid:365d" in prompt
+ # After chat-format externalization, prompt is [SystemMessage, HumanMessage];
+ # the staleness section is injected into the user message.
+ assert any("valid:365d" in getattr(m, "content", "") for m in prompt)
diff --git a/backend/tests/test_message_processing.py b/backend/tests/test_message_processing.py
new file mode 100644
index 000000000..d40ad95ca
--- /dev/null
+++ b/backend/tests/test_message_processing.py
@@ -0,0 +1,131 @@
+"""Tests for message_processing: externalized signal patterns.
+
+Covers:
+ - load_patterns (bundled defaults, caching, custom dir override, missing file)
+ - detect_correction / detect_reinforcement (backward-compatible signature,
+ patterns override, last-6 window)
+ - DeerMem._prepare_update (3-tuple, returns None when missing a role)
+ - DeerMemConfig.patterns_dir default
+"""
+
+import re
+
+from langchain_core.messages import AIMessage, HumanMessage
+
+from deerflow.agents.memory.backends.deermem.deer_mem import DeerMem
+from deerflow.agents.memory.backends.deermem.deermem.config import DeerMemConfig
+from deerflow.agents.memory.backends.deermem.deermem.core.message_processing import (
+ detect_correction,
+ detect_reinforcement,
+ filter_messages_for_memory,
+ load_patterns,
+)
+
+
+def _human(text: str) -> HumanMessage:
+ return HumanMessage(content=text)
+
+
+def _ai(text: str) -> AIMessage:
+ return AIMessage(content=text)
+
+
+# ---------------------------------------------------------------------------
+# load_patterns
+# ---------------------------------------------------------------------------
+
+
+def test_load_patterns_bundled_nonempty():
+ assert len(load_patterns("correction")) > 0
+ assert len(load_patterns("reinforcement")) > 0
+
+
+def test_load_patterns_cached():
+ assert load_patterns("correction") is load_patterns("correction")
+
+
+def test_load_patterns_custom_dir_overrides(tmp_path):
+ (tmp_path / "correction.yaml").write_text("- 'foobarbaz'\n", encoding="utf-8")
+ pats = load_patterns("correction", patterns_dir=str(tmp_path))
+ assert len(pats) == 1
+ assert pats[0].search("hello foobarbaz world")
+ # bundled defaults remain intact (different cache key)
+ assert len(load_patterns("correction")) > 1
+
+
+def test_load_patterns_missing_file_explicit_dir_raises(tmp_path):
+ import pytest
+
+ with pytest.raises(FileNotFoundError, match="nope"):
+ load_patterns("nope", patterns_dir=str(tmp_path))
+
+
+# ---------------------------------------------------------------------------
+# detect_correction / detect_reinforcement
+# ---------------------------------------------------------------------------
+
+
+def test_detect_correction_default_bundled():
+ msgs = [_human("That's wrong, use uv"), _ai("ok")]
+ assert detect_correction(msgs) is True
+ assert detect_reinforcement(msgs) is False
+
+
+def test_detect_reinforcement_default_bundled():
+ msgs = [_human("perfect, exactly right"), _ai("great")]
+ assert detect_reinforcement(msgs) is True
+
+
+def test_detect_correction_patterns_override():
+ custom = [re.compile(r"zzz")]
+ assert detect_correction([_human("zzz here")], patterns=custom) is True
+ assert detect_correction([_human("That's wrong")], patterns=custom) is False
+
+
+def test_detect_window_is_last_six():
+ # 7 human turns; a correction in the oldest (outside [-6:]) is not detected.
+ msgs = [_human(f"msg {i}") for i in range(7)]
+ msgs[0] = _human("That's wrong, old")
+ assert detect_correction(msgs) is False
+ msgs[-1] = _human("That's wrong, recent")
+ assert detect_correction(msgs) is True
+
+
+# ---------------------------------------------------------------------------
+# DeerMem._prepare_update (3-tuple, signal detection with externalized patterns)
+# ---------------------------------------------------------------------------
+
+
+def _make_deermem(tmp_path) -> DeerMem:
+ return DeerMem(backend_config={"storage_path": str(tmp_path)})
+
+
+def test_prepare_update_missing_role_returns_none(tmp_path):
+ m = _make_deermem(tmp_path)
+ assert m._prepare_update([_human("only human")]) is None
+ assert m._prepare_update([_ai("only ai")]) is None
+ assert m._prepare_update([]) is None
+
+
+def test_prepare_update_returns_3tuple_with_correction_true(tmp_path):
+ m = _make_deermem(tmp_path)
+ r = m._prepare_update([_human("That's wrong, use uv"), _ai("ok")])
+ assert r is not None and len(r) == 3
+ filtered, corr, rein = r
+ assert corr is True
+ assert rein is False
+ assert len(filtered) == 2
+
+
+# ---------------------------------------------------------------------------
+# Config defaults
+# ---------------------------------------------------------------------------
+
+
+def test_config_patterns_dir_default():
+ assert DeerMemConfig().patterns_dir is None
+
+
+def test_filter_messages_backward_compat():
+ filtered = filter_messages_for_memory([_human("hello"), _ai("hi there")])
+ assert len(filtered) == 2
diff --git a/backend/tests/test_prompt_external.py b/backend/tests/test_prompt_external.py
new file mode 100644
index 000000000..4fc8a223a
--- /dev/null
+++ b/backend/tests/test_prompt_external.py
@@ -0,0 +1,137 @@
+"""Tests for externalized prompt templates (06-prompt).
+
+``memory_update`` uses the chat form (:func:`load_prompt_messages`, system/user
+split -- mirrors the lead agent's static system). The injected text sections
+(staleness_review / consolidation / fact_extraction) use :func:`load_prompt`.
+Covers: bundled defaults, ``.format`` rendering, missing-file error, the
+``prompts_dir`` / ``agent_name`` override path, and chat system/user split +
+byte-stability.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+from langchain_core.messages import HumanMessage, SystemMessage
+
+from deerflow.agents.memory.backends.deermem.deermem.core.prompt import (
+ CONSOLIDATION_PROMPT,
+ FACT_EXTRACTION_PROMPT,
+ STALENESS_REVIEW_PROMPT,
+ load_prompt,
+ load_prompt_messages,
+)
+
+
+def test_load_prompt_returns_bundled_defaults() -> None:
+ # The injected text sections are non-empty, carry their role line, and
+ # preserve .format placeholders. (memory_update is chat, tested below.)
+ sr = load_prompt("staleness_review")
+ assert "Staleness Review" in sr
+ assert "{stale_facts}" in sr
+
+ co = load_prompt("consolidation")
+ assert "Memory Consolidation" in co
+ assert "{consolidation_groups}" in co
+ assert "{max_groups}" in co
+
+ fe = load_prompt("fact_extraction")
+ assert "Extract factual information" in fe
+ assert "{message}" in fe
+
+
+def test_shim_constants_equal_load_prompt_default() -> None:
+ # The injected-section shim aliases each load the bundled default
+ # (byte-identical), so updater.py's ``CONST.format(...)`` behaves unchanged.
+ assert STALENESS_REVIEW_PROMPT == load_prompt("staleness_review")
+ assert CONSOLIDATION_PROMPT == load_prompt("consolidation")
+ assert FACT_EXTRACTION_PROMPT == load_prompt("fact_extraction")
+
+
+def test_load_prompt_missing_raises() -> None:
+ with pytest.raises(FileNotFoundError):
+ load_prompt("does_not_exist")
+
+
+def test_load_prompt_custom_prompts_dir(tmp_path: Path) -> None:
+ # prompts_dir (global override): a custom default file wins over bundled.
+ (tmp_path / "memory_update.yaml").write_text('format: text\nversion: "9.9"\ntemplate: |\n CUSTOM GLOBAL PROMPT\n', encoding="utf-8")
+ result = load_prompt("memory_update", prompts_dir=str(tmp_path))
+ assert "CUSTOM GLOBAL PROMPT" in result
+ # A name with no file in the custom dir -> FileNotFoundError (no fallback to bundled).
+ with pytest.raises(FileNotFoundError):
+ load_prompt("staleness_review", prompts_dir=str(tmp_path))
+
+
+def test_load_prompt_agent_override_resolves(tmp_path: Path) -> None:
+ # agent_name override: {prompts_dir}/{agent_name}/{name}.yaml wins over the
+ # default {prompts_dir}/{name}.yaml; absent override falls back to default.
+ (tmp_path / "memory_update.yaml").write_text('format: text\nversion: "1.0"\ntemplate: |\n DEFAULT PROMPT\n', encoding="utf-8")
+ (tmp_path / "researcher").mkdir()
+ (tmp_path / "researcher" / "memory_update.yaml").write_text('format: text\nversion: "1.0"\ntemplate: |\n RESEARCHER-SPECIFIC PROMPT\n', encoding="utf-8")
+ # Agent "researcher" has an override -> custom template.
+ assert "RESEARCHER-SPECIFIC PROMPT" in load_prompt("memory_update", agent_name="researcher", prompts_dir=str(tmp_path))
+ # Agent "other" has no override dir -> falls back to the default.
+ assert "DEFAULT PROMPT" in load_prompt("memory_update", agent_name="other", prompts_dir=str(tmp_path))
+ # "researcher" override does NOT leak into "other" (isolation).
+ other = load_prompt("memory_update", agent_name="other", prompts_dir=str(tmp_path))
+ assert "RESEARCHER-SPECIFIC" not in other
+
+
+def test_load_prompt_messages_returns_system_user() -> None:
+ # Chat form: system (static rules) + user (dynamic placeholders).
+ variables = {
+ "current_memory": "CM-VAL",
+ "conversation": "CONV-VAL",
+ "correction_hint": "CH-VAL",
+ "staleness_review_section": "SRS-VAL",
+ "consolidation_section": "CS-VAL",
+ }
+ messages = load_prompt_messages("memory_update", variables)
+ assert len(messages) == 2
+ assert isinstance(messages[0], SystemMessage)
+ assert isinstance(messages[1], HumanMessage)
+ # System carries the static rules + JSON schema (single braces after .format).
+ assert "memory management system" in messages[0].content
+ assert '"user": {' in messages[0].content
+ assert "{{" not in messages[0].content
+ assert "Return ONLY valid JSON" in messages[0].content
+ # User carries the 5 dynamic placeholders, all substituted.
+ assert "CM-VAL" in messages[1].content
+ assert "CONV-VAL" in messages[1].content
+ assert "CH-VAL" in messages[1].content
+ assert "SRS-VAL" in messages[1].content
+ assert "CS-VAL" in messages[1].content
+ assert "" in messages[1].content
+
+
+def test_load_prompt_messages_system_byte_stable_across_vars() -> None:
+ # The system message has no variables -> renders byte-identical regardless of
+ # the per-call vars (prefix-cache friendly, mirrors lead agent's static system).
+ vars_a = {
+ "current_memory": "AAA",
+ "conversation": "AAA",
+ "correction_hint": "AAA",
+ "staleness_review_section": "AAA",
+ "consolidation_section": "AAA",
+ }
+ vars_b = {
+ "current_memory": "BBB",
+ "conversation": "BBB",
+ "correction_hint": "BBB",
+ "staleness_review_section": "BBB",
+ "consolidation_section": "BBB",
+ }
+ sys_a = load_prompt_messages("memory_update", vars_a)[0].content
+ sys_b = load_prompt_messages("memory_update", vars_b)[0].content
+ assert sys_a == sys_b
+ # And neither contains the per-call values (vars are in user, not system).
+ assert "AAA" not in sys_a
+ assert "BBB" not in sys_b
+
+
+def test_load_prompt_messages_missing_raises() -> None:
+ # No chat yaml for staleness_review -> FileNotFoundError.
+ with pytest.raises(FileNotFoundError):
+ load_prompt_messages("staleness_review", {})
diff --git a/config.example.yaml b/config.example.yaml
index 3848691ee..0e6583692 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: 27
+config_version: 28
# ============================================================================
# Logging
@@ -1561,6 +1561,17 @@ memory:
consolidation_min_facts: 8 # min facts in one category to trigger review (3-30)
consolidation_max_groups_per_cycle: 3 # max groups merged per update cycle (1-10)
consolidation_max_sources: 8 # max source facts per consolidation group (2-20)
+ # Message processing (externalized patterns / prompt templates):
+ # patterns_dir - dir with correction.yaml / reinforcement.yaml overriding
+ # the bundled signal-detection patterns. None (default) =
+ # bundled core/message_patterns/. When explicitly set, both
+ # files must exist (typos or missing mounts raise an error).
+ # prompts_dir - dir with custom memory-extraction prompt templates
+ # (memory_update.chat.yaml, staleness_review.yaml,
+ # consolidation.yaml, fact_extraction.yaml). None (default)
+ # = bundled core/prompts/. Supports per-agent subdirectories.
+ # patterns_dir: "" # empty = bundled defaults
+ # prompts_dir: "" # empty = bundled defaults
# ============================================================================
# Custom Agent Management API
diff --git a/deploy/helm/deer-flow/README.md b/deploy/helm/deer-flow/README.md
index 741c2f0d3..6eb8fdbf1 100644
--- a/deploy/helm/deer-flow/README.md
+++ b/deploy/helm/deer-flow/README.md
@@ -124,7 +124,7 @@ they resolve from the `secrets` map):
```yaml
config: |
- config_version: 27
+ config_version: 28
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 5a3762bd4..7fb10bd55 100644
--- a/deploy/helm/deer-flow/values.yaml
+++ b/deploy/helm/deer-flow/values.yaml
@@ -240,7 +240,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: 27
+ config_version: 28
log_level: info
models: []