Files
deer-flow/backend/tests/test_prompt_external.py
de024646a7 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>
2026-07-20 07:24:34 +08:00

138 lines
5.9 KiB
Python

"""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", {})