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:
lllyfff
2026-07-20 07:24:34 +08:00
committed by GitHub
co-authored by Claude
parent a8bf54cbbb
commit de024646a7
18 changed files with 917 additions and 286 deletions
@@ -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,
@@ -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]
- '不对'
- '你理解错了'
- '你理解有误'
- '重试'
- '重新来'
- '换一种'
- '改用'
@@ -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*)?就是这个意思(?:[。!?!?.]|$)'
- '正是我想要的(?:[。!?!?.]|$)'
- '继续保持(?:[。!?!?.]|$)'
@@ -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
@@ -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-
@@ -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
@@ -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.
@@ -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.
@@ -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.
@@ -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(
+6 -3
View File
@@ -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)
+131
View File
@@ -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
+137
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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: []