mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-21 02:05:45 +00:00
feat(memory): memory template externalization (#4286)
* feat(memory): template externalization — externalized prompts + signal patterns Externalize two categories of hardcoded content in the DeerMem memory backend so operators can customise them without touching Python code. Prompt externalization (plugin 06): - 4 memory extraction prompts moved from Python string constants to YAML files under core/prompts/ (consolidation / fact_extraction / memory_update.chat / staleness_review). - load_prompt(name) and load_prompt_messages(name, variables) loaders. - memory_update uses the chat format (system / user message split) for prompt-caching-friendly system prefixes; other prompts stay text. - The extraction callback + per-agent prompt directories + Jinja2 dependency are intentionally not included (minimal surface). - Compatible with the upstream prompt additions from #4143 (expected_valid_days, staleFactsToExtend, KEEP/REMOVE/EXTEND in staleness review). Signal-pattern externalization (plugin 07, regex only): - Correction and reinforcement detection patterns externalised to core/message_patterns/{correction,reinforcement}.yaml. - load_patterns(name, patterns_dir) loader with bundled defaults. - detect_correction / detect_reinforcement accept a keyword-only patterns= parameter; signatures are backward-compatible. - patterns_dir config field added to DeerMemConfig. - Importance scorer (importance.py, build_importance_scorer, _prepare_update changes) is NOT included — deferred to a later PR. Co-Authored-By: Claude <noreply@anthropic.com> * fix(memory): fail loudly on invalid yaml in prompt/pattern loaders Replace silent error handling in load_prompt / load_prompt_messages / load_patterns so malformed yaml or missing required keys raise ValueError with the file path rather than a raw YAMLError traceback or silent empty-string / empty-list return. Changes: - load_prompt: YAMLError -> ValueError(path); missing/empty 'template' key -> ValueError - load_prompt_messages: YAMLError -> ValueError(path); missing/empty 'messages' key -> ValueError; KeyError from .format (placeholder mismatch) -> ValueError - load_patterns: YAMLError -> ValueError (was silent warn+return []). OSError still degrades (permission, not format). Not-a-list yaml -> ValueError (was silent warn+return []). Co-Authored-By: Claude <noreply@anthropic.com> * fix(memory): wire prompts_dir through updater, fail-loud patterns_dir, bump config_version Addresses PR feedback on the template-externalization PR: P1 — Wire prompts_dir into the DeerMem update path: - Add prompts_dir field to DeerMemConfig (default None = bundled defaults). - Thread it through DeerMem -> MemoryUpdater.__init__(prompts_dir=). - _build_staleness_section / _build_consolidation_section accept prompts_dir= keyword and call load_prompt() instead of relying on module-level shim constants. - _prepare_update_prompt passes prompts_dir to load_prompt_messages and to both section builders. - Add _PROMPT_CACHE to load_prompt so repeated lookups (once per memory-update cycle) do not re-read yaml from disk. P2 — Explicit patterns_dir must find its files: - When patterns_dir is explicitly set and a YAML file is missing, load_patterns() raises FileNotFoundError instead of silently caching an empty list. Bundled defaults (patterns_dir=None) still log a WARNING and return [] for a missing bundled file (packaging bug). P3 — Bump config_version and sync Helm: - config.example.yaml: 26 -> 27 - deploy/helm/deer-flow/values.yaml: 26 -> 27 - deploy/helm/deer-flow/README.md: 26 -> 27 - scripts/check_config_version.sh confirms parity. Tests: 224 passed (218 memory + 6 config_version). Lint: ruff check + ruff format --check pass. Co-Authored-By: Claude <noreply@anthropic.com> * fix(memory): cache load_prompt_messages, validate format fields, fail-loud load_patterns Review feedback from willem-bd: Cache for load_prompt_messages: Add _CHAT_TEMPLATE_CACHE + _render_messages() helper so the parsed chat templates are cached per (name, agent, prompts_dir). On cache hit only .format() rendering runs; the yaml file is read once per key. Validate format field in both loaders: load_prompt rejects format='chat' (redirects to load_prompt_messages); load_prompt_messages rejects format='text' (redirects to load_prompt). Prevents operators from loading a chat yaml via the text loader (or vice versa) without a clear error. Load_patterns fail-loud for explicit directories: - OSError (permission, etc.) now raises for explicit patterns_dir instead of silently disabling detection. - Malformed entries (missing/empty pattern key, wrong type) are skipped with a WARNING instead of silent skip. - Unknown flag names are warned instead of silently dropped. - re.error from compile() raises ValueError with file path and entry index instead of a bare re.error traceback. Co-Authored-By: Claude <noreply@anthropic.com> * fix(memory): thread agent_name through section builders, validate explicit prompts at construction, bump config_version to 28 P1 — Thread agent_name through staleness/consolidation section builders: _build_staleness_section and _build_consolidation_section now accept agent_name= and pass it to load_prompt(), so per-agent prompt overrides work for section templates too (not just memory_update). Previously only prompts_dir was threaded; agent_name was ignored for section builders. P1 — Validate explicit prompts at DeerMem construction: When DeerMemConfig.prompts_dir is explicitly set, DeerMem.__init__ now pre-loads all four prompt templates (staleness_review, consolidation, fact_extraction, memory_update with dummy variables) at construction time. A missing file, malformed YAML, or invalid placeholder raises immediately at startup instead of being caught by the updater's generic error handler and silently dropped as a failed update. P2 — Advance config_version to 28: Upstream main already consumed the previous 26→27 bump with a different schema change. Bump config.example.yaml, Helm values.yaml, and Helm README.md to 28 to version this PR's patterns_dir and prompts_dir additions. Co-Authored-By: Claude <noreply@anthropic.com> * fix(memory): render text templates at construction, propagate PromptConfigurationError Keep prompt-configuration failures out of the recoverable update catch: - Define PromptConfigurationError(ValueError) in prompt.py. Raised by load_prompt, load_prompt_messages, and _render_messages for bad yaml, missing keys, and invalid placeholders. - _do_update_memory_sync_impl, update_memory's executor path, and _process_queue all re-raise (PromptConfigurationError, FileNotFoundError, OSError) before the generic except Exception, so a bad explicit prompt is never silently returned as False. - DeerMem.__init__ prompt validation now renders text templates with dummy variables (.format(stale_facts="") etc.) so an unknown placeholder in staleness_review.yaml, consolidation.yaml, or fact_extraction.yaml is caught at construction. Co-Authored-By: Claude <noreply@anthropic.com> * fix(memory): drop re-raise guards, soften validation comment, exclude dormant fact_extraction - Remove (PromptConfigurationError, FileNotFoundError, OSError) re-raise guards from _do_update_memory_sync_impl, update_memory executor path, and _process_queue. The existing except Exception: logger.exception(...) already logs prompt-config errors at ERROR with full traceback; the re-raise was killing the entire batch and only surfacing via stderr. Per-agent override errors now log at ERROR per-item without aborting co-tenant updates. - Soften the construction validation comment to clarify it only covers global templates. Per-agent overrides are validated lazily at first use and logged at ERROR by the updater's exception handler. - Drop fact_extraction from construction validation (dormant prompt with no runtime caller; the shim + yaml remain for backward compat). Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
+26
@@ -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]
|
||||
- '不对'
|
||||
- '你理解错了'
|
||||
- '你理解有误'
|
||||
- '重试'
|
||||
- '重新来'
|
||||
- '换一种'
|
||||
- '改用'
|
||||
+36
@@ -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*)?就是这个意思(?:[。!?!?.]|$)'
|
||||
- '正是我想要的(?:[。!?!?.]|$)'
|
||||
- '继续保持(?:[。!?!?.]|$)'
|
||||
+105
-34
@@ -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"<uploaded_files>[\s\S]*?</uploaded_files>\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: <regex>, 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
|
||||
|
||||
+151
-220
@@ -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>
|
||||
{current_memory}
|
||||
</current_memory>
|
||||
_PROMPTS_DEFAULT_DIR = Path(__file__).resolve().parent / "prompts"
|
||||
|
||||
New Conversation to Process:
|
||||
<conversation>
|
||||
{conversation}
|
||||
</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>
|
||||
{stale_facts}
|
||||
</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 <memory> block of the lead-agent system prompt. Escape
|
||||
# them so a value like "</memory></system-reminder>" 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 <conversation> 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
|
||||
# "</conversation><current_memory>..." would close the block and forge a
|
||||
# <current_memory> authority section for the extraction LLM. Same block-
|
||||
|
||||
+25
@@ -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
|
||||
+29
@@ -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.
|
||||
+147
@@ -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>
|
||||
{current_memory}
|
||||
</current_memory>
|
||||
|
||||
New Conversation to Process:
|
||||
<conversation>
|
||||
{conversation}
|
||||
</conversation>
|
||||
|
||||
{correction_hint}
|
||||
|
||||
{staleness_review_section}
|
||||
|
||||
{consolidation_section}
|
||||
|
||||
Update the memory based on the above conversation.
|
||||
+37
@@ -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>
|
||||
{stale_facts}
|
||||
</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.
|
||||
+27
-16
@@ -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'<consolidation_candidates category="{html.escape(cat)}" count="{shown}">\n' + "\n".join(lines) + "\n</consolidation_candidates>")
|
||||
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 ``<current_memory>...</current_memory>`` 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 "<stale_facts>" 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 "<stale_facts>" 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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 "<current_memory>" 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", {})
|
||||
+12
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: []
|
||||
|
||||
Reference in New Issue
Block a user