From de024646a7cb1a0eddad33e5759367d04a035607 Mon Sep 17 00:00:00 2001 From: lllyfff <122260771+lllyfff@users.noreply.github.com> Date: Mon, 20 Jul 2026 07:24:34 +0800 Subject: [PATCH] feat(memory): memory template externalization (#4286) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude --- .../memory/backends/deermem/deer_mem.py | 34 +- .../memory/backends/deermem/deermem/config.py | 9 + .../core/message_patterns/correction.yaml | 26 ++ .../core/message_patterns/reinforcement.yaml | 36 ++ .../deermem/core/message_processing.py | 139 +++++-- .../backends/deermem/deermem/core/prompt.py | 371 +++++++----------- .../deermem/core/prompts/consolidation.yaml | 25 ++ .../deermem/core/prompts/fact_extraction.yaml | 29 ++ .../core/prompts/memory_update.chat.yaml | 147 +++++++ .../core/prompts/staleness_review.yaml | 37 ++ .../backends/deermem/deermem/core/updater.py | 43 +- backend/tests/test_memory_consolidation.py | 9 +- backend/tests/test_memory_staleness_review.py | 13 +- backend/tests/test_message_processing.py | 131 +++++++ backend/tests/test_prompt_external.py | 137 +++++++ config.example.yaml | 13 +- deploy/helm/deer-flow/README.md | 2 +- deploy/helm/deer-flow/values.yaml | 2 +- 18 files changed, 917 insertions(+), 286 deletions(-) create mode 100644 backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/correction.yaml create mode 100644 backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/message_patterns/reinforcement.yaml create mode 100644 backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/consolidation.yaml create mode 100644 backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/fact_extraction.yaml create mode 100644 backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/memory_update.chat.yaml create mode 100644 backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/prompts/staleness_review.yaml create mode 100644 backend/tests/test_message_processing.py create mode 100644 backend/tests/test_prompt_external.py 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: []