mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-27 00:17:53 +00:00
feat(memory): add memory consolidation to synthesize fragmented facts (#3996)
* feat(memory): add memory consolidation to synthesize fragmented facts When a fact category accumulates many individual entries, the LLM reviews them during the normal memory-update call (same invocation, no extra API cost) and decides whether groups of related facts can be synthesized into a single richer fact. This completes the memory lifecycle: extraction → guaranteed injection → staleness review → consolidation. - Select fragmented categories by min-facts threshold, surface the most fragmented groups first; prompt-layer caps aligned with apply-layer guardrails so the LLM never sees groups it cannot act on - Cap consolidated confidence at source maximum to prevent inflation; reject results below fact_confidence_threshold - Double-consume protection prevents a fact from being merged into multiple consolidation targets - Feature-gated at both prompt and apply time with per-cycle safety caps - Add 26 tests covering candidate selection, normalization, apply guardrails, and prompt integration * fix(memory): address consolidation correctness issues from PR review Six fixes based on maintainer review of #3996: 1. Deduplicate sourceIds in normalization — ["f1","f1"] previously bypassed the ≥2-distinct-sources check; dict.fromkeys collapses it to ["f1"] which is correctly rejected. 2. Run consolidation after max_facts trim — previously, sources were deleted then the merged fact could be evicted by the trim, leaving no record of either. Moving consolidation last ensures source facts exist in the post-trim index before removal. 3. Fix count= attribute in consolidation prompt — advertised the full category size but listed only max_sources IDs; now uses min(len(group), max_sources) to match what the LLM can act on. 4. Exempt staleness_protected_categories from consolidation candidates — mirrors the existing staleness-review contract so correction facts are never surfaced for merging. 5. Strip and default category in consolidation normalization — " " or " preference " are now normalised, matching _normalize_memory_update_fact. 6. Propagate sourceError from source facts into consolidated fact — correction context is no longer silently lost on merge. * fix(memory): add apply-time guardrails and tests for consolidation P1: mirror the staleness-pass defense-in-depth pattern — build allowed_source_ids from _select_consolidation_candidates at apply time so a protected-category or below-threshold fact proposed by the LLM is rejected regardless of model behavior. P2a: test that LLM-returned confidence is capped at max source confidence and that a capped result below fact_confidence_threshold is rejected. P2b: test that factsToConsolidate with consolidation_enabled=False is a no-op at apply time (35 tests, all pass). * fix(memory): address three correctness issues from second review round 1. Default consolidation_enabled=False — consolidation is lossy (source content is permanently replaced, only consolidatedFrom IDs preserved); new lossy features default to off. config.example.yaml updated to match. 2. Unify confidence coercion between prompt and apply — _build_consolidation_section now calls _coerce_source_confidence(fact) instead of an inline 0.0-default coercion, so a null-confidence fact renders with 0.50 in the LLM prompt and is capped at 0.50 at apply time (same value, same function). 3. Preserve staleness clock on merge — consolidated fact now carries the newest source's createdAt (not now) so aged information does not gain a fresh staleness-review window just by being consolidated; consolidatedAt is added as an explicit audit field. Three regression tests added (default=false, null-confidence consistency, createdAt policy); all guardrail tests now set consolidation_enabled=True explicitly so they test the guardrail, not the feature flag. 38 tests pass. * fix(memory): harden createdAt comparison and confidence handling 1. createdAt max via _parse_fact_datetime — replaces string max() which crashes on non-string createdAt (numeric unix timestamps) and sorts Z/+00:00 mixed formats incorrectly. Mirrors how staleness computes age. 2. Remove dead min(..., 1.0) — _coerce_source_confidence already clamps each source confidence to [0, 1], so max(source_confidences) ≤ 1.0 by contract; the outer min could never bind. 3. Clamp raw_llm_conf to [0, 1] before applying the source cap — out-of- range values like 1.5 are safe today (pinned by the cap) but defensively clamped first so the invariant holds even if the cap is ever loosened. 4. Doc: expand the apply-time guardrails comment to call out the protected- category exclusion via allowed_source_ids — this is the central safety property ("explicit user feedback is never silently merged away"). 5. Test: add test_confidence_fallback_to_max_source_when_llm_omits_field covering the else-branch (LLM omits confidence → uses max_source_conf). 6. Fix lint: reorder imports in test file (stdlib before third-party). 39 tests, all pass. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
266883b3dd
commit
9097642658
@ -574,6 +574,7 @@ The cached value is reused for both the blocking (`runs.wait`) and streaming (`_
|
||||
3. Background thread invokes LLM to extract context updates and facts, using the stored `user_id` (not the contextvar, which is unavailable on timer threads)
|
||||
4. Applies updates atomically (temp file + rename) with cache invalidation, skipping duplicate fact content before append
|
||||
5. **Staleness pass** (same LLM invocation as step 3, no extra API call): when `staleness_review_enabled` is `true` and at least `staleness_min_candidates` aged facts exist, `_select_stale_candidates` selects facts older than `staleness_age_days` that are not in `staleness_protected_categories` (default: `correction`), surfaces them in the prompt, and the LLM judges each as KEEP or REMOVE. `_apply_updates` enforces the guardrail unconditionally at apply time: it intersects the LLM-returned removal set with `_select_stale_candidates` output before applying the per-cycle cap (`staleness_max_removals_per_cycle`), so protected and non-aged facts can never be deleted regardless of model behavior or the feature flag setting.
|
||||
5b. **Consolidation pass** (same LLM invocation as step 3, no extra API call): when `consolidation_enabled` is `true` and at least one category holds `consolidation_min_facts` or more facts, `_select_consolidation_candidates` identifies fragmented categories and surfaces at most `consolidation_max_groups_per_cycle` of them (largest first) in the prompt. The LLM decides which groups to merge and proposes a synthesised fact per group. `_apply_updates` enforces guardrails: source IDs must exist and must not overlap across groups, group size is capped at `consolidation_max_sources`, the merged fact's confidence cannot exceed the source maximum, and facts below `fact_confidence_threshold` are not written.
|
||||
6. Next interaction injects top 15 facts + context into `<memory>` tags in system prompt
|
||||
|
||||
**Token counting** (`packages/harness/deerflow/agents/memory/prompt.py`):
|
||||
@ -597,6 +598,10 @@ Focused regression coverage for the updater lives in `backend/tests/test_memory_
|
||||
- `staleness_min_candidates` - Minimum aged candidates required to trigger a review cycle (default: 3; range: 1–50)
|
||||
- `staleness_max_removals_per_cycle` - Maximum facts removed in a single cycle; lowest-confidence entries are kept when the LLM requests more (default: 5; range: 1–20)
|
||||
- `staleness_protected_categories` - Fact categories that are never pruned by staleness review (default: `["correction"]`)
|
||||
- `consolidation_enabled` - Enable memory consolidation (default: `true`; no extra API call — runs in the same LLM invocation as the normal memory update)
|
||||
- `consolidation_min_facts` - Minimum facts in a category to trigger consolidation review (default: 8; range: 3–30)
|
||||
- `consolidation_max_groups_per_cycle` - Maximum categories the LLM can merge in one cycle (default: 3; range: 1–10; also controls the LLM's prompt instruction)
|
||||
- `consolidation_max_sources` - Maximum source facts per merge group; prevents over-merging (default: 8; range: 2–20)
|
||||
|
||||
### Reflection System (`packages/harness/deerflow/reflection/`)
|
||||
|
||||
|
||||
@ -116,7 +116,13 @@ Output Format (JSON):
|
||||
{{ "content": "...", "category": "preference|knowledge|context|behavior|goal|correction", "confidence": 0.0-1.0 }}
|
||||
],
|
||||
"factsToRemove": ["fact_id_1", "fact_id_2"],
|
||||
"staleFactsToRemove": [{{ "id": "fact_id", "reason": "brief explanation" }}]
|
||||
"staleFactsToRemove": [{{ "id": "fact_id", "reason": "brief explanation" }}],
|
||||
"factsToConsolidate": [
|
||||
{{
|
||||
"sourceIds": ["fact_id_1", "fact_id_2"],
|
||||
"consolidated": {{ "content": "synthesized fact", "category": "knowledge", "confidence": 0.9 }}
|
||||
}}
|
||||
]
|
||||
}}
|
||||
|
||||
Important Rules:
|
||||
@ -138,6 +144,8 @@ Important Rules:
|
||||
|
||||
{staleness_review_section}
|
||||
|
||||
{consolidation_section}
|
||||
|
||||
Return ONLY valid JSON, no explanation or markdown."""
|
||||
|
||||
|
||||
@ -170,6 +178,33 @@ 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."""
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
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"""
|
||||
|
||||
|
||||
# Prompt template for extracting facts from a single message
|
||||
FACT_EXTRACTION_PROMPT = """Extract factual information about the user from this message.
|
||||
|
||||
|
||||
@ -15,6 +15,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from deerflow.agents.memory.prompt import (
|
||||
CONSOLIDATION_PROMPT,
|
||||
MEMORY_UPDATE_PROMPT,
|
||||
STALENESS_REVIEW_PROMPT,
|
||||
format_conversation_for_update,
|
||||
@ -94,6 +95,24 @@ def _validate_confidence(confidence: float) -> float:
|
||||
return confidence
|
||||
|
||||
|
||||
def _coerce_source_confidence(fact: dict[str, Any]) -> float:
|
||||
"""Return a stored fact's confidence as a finite float in [0, 1], defaulting to 0.5.
|
||||
|
||||
dict.get(key, default) returns the stored value (including None) when the key
|
||||
exists, so a fact written with "confidence": null would propagate None into
|
||||
arithmetic and crash max(). This helper guards against null, bool, non-numeric,
|
||||
and non-finite values from corrupted or manually edited memory files.
|
||||
"""
|
||||
raw = fact.get("confidence")
|
||||
if raw is None or isinstance(raw, bool):
|
||||
return 0.5
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 0.5
|
||||
return max(0.0, min(val, 1.0)) if math.isfinite(val) else 0.5
|
||||
|
||||
|
||||
def create_memory_fact(
|
||||
content: str,
|
||||
category: str = "context",
|
||||
@ -321,12 +340,56 @@ def _normalize_memory_update_data(update_data: dict[str, Any]) -> dict[str, Any]
|
||||
}
|
||||
)
|
||||
|
||||
# ── Normalize consolidation decisions ──
|
||||
consolidation_raw = update_data.get("factsToConsolidate")
|
||||
normalized_consolidation: list[dict[str, Any]] = []
|
||||
if isinstance(consolidation_raw, list):
|
||||
for entry in consolidation_raw:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
source_ids = entry.get("sourceIds")
|
||||
if not isinstance(source_ids, list) or not source_ids:
|
||||
continue
|
||||
# dict.fromkeys preserves order while deduplicating so ["f1","f1"]
|
||||
# collapses to ["f1"] and is correctly rejected as a single-source merge.
|
||||
clean_ids = list(dict.fromkeys(sid for sid in source_ids if isinstance(sid, str) and sid))
|
||||
if len(clean_ids) < 2:
|
||||
continue
|
||||
consolidated = entry.get("consolidated")
|
||||
if not isinstance(consolidated, dict):
|
||||
continue
|
||||
content = consolidated.get("content")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
continue
|
||||
# Normalize confidence: reject booleans (bool subclasses int, so the
|
||||
# isinstance check alone would silently accept True/False), coerce to float,
|
||||
# and reject non-finite values — matching _normalize_memory_update_fact.
|
||||
_raw_conf = consolidated.get("confidence", 0.9)
|
||||
if isinstance(_raw_conf, bool) or not isinstance(_raw_conf, (int, float)):
|
||||
_norm_conf = 0.9
|
||||
else:
|
||||
_f = float(_raw_conf)
|
||||
_norm_conf = _f if math.isfinite(_f) else 0.9
|
||||
_raw_cat = consolidated.get("category")
|
||||
_norm_cat = _raw_cat.strip() if isinstance(_raw_cat, str) and _raw_cat.strip() else "context"
|
||||
normalized_consolidation.append(
|
||||
{
|
||||
"sourceIds": clean_ids,
|
||||
"consolidated": {
|
||||
"content": content.strip(),
|
||||
"category": _norm_cat,
|
||||
"confidence": _norm_conf,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"user": user if isinstance(user, dict) else {},
|
||||
"history": history if isinstance(history, dict) else {},
|
||||
"newFacts": normalized_new_facts,
|
||||
"factsToRemove": normalized_facts_to_remove,
|
||||
"staleFactsToRemove": normalized_stale_removals,
|
||||
"factsToConsolidate": normalized_consolidation,
|
||||
}
|
||||
|
||||
|
||||
@ -464,6 +527,61 @@ def _build_staleness_section(
|
||||
)
|
||||
|
||||
|
||||
# ── Consolidation helpers ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _select_consolidation_candidates(
|
||||
current_memory: dict[str, Any],
|
||||
config: Any,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Return fact categories that exceed the fragmentation threshold.
|
||||
|
||||
Groups facts by category; only categories with at least
|
||||
``consolidation_min_facts`` entries are returned.
|
||||
"""
|
||||
facts = current_memory.get("facts", [])
|
||||
if not facts:
|
||||
return {}
|
||||
by_category: dict[str, list[dict[str, Any]]] = {}
|
||||
for fact in facts:
|
||||
if not isinstance(fact, dict):
|
||||
continue
|
||||
cat = fact.get("category", "context")
|
||||
if isinstance(cat, str) and cat.strip():
|
||||
by_category.setdefault(cat.strip(), []).append(fact)
|
||||
threshold = config.consolidation_min_facts
|
||||
protected = set(config.staleness_protected_categories)
|
||||
return {cat: group for cat, group in by_category.items() if len(group) >= threshold and cat not in protected}
|
||||
|
||||
|
||||
def _build_consolidation_section(
|
||||
candidates: dict[str, list[dict[str, Any]]],
|
||||
max_groups: int = 3,
|
||||
max_sources: int = 8,
|
||||
) -> str:
|
||||
"""Format consolidation candidate groups into the prompt section.
|
||||
|
||||
Surfaces at most ``max_groups`` categories (largest fragmented groups first)
|
||||
and at most ``max_sources`` facts per group, matching the caps enforced at
|
||||
apply time so the LLM is never shown groups it cannot act on.
|
||||
"""
|
||||
if not candidates:
|
||||
return ""
|
||||
# Prioritise the most fragmented categories; alphabetical tiebreak for stability.
|
||||
sorted_candidates = sorted(candidates.items(), key=lambda kv: (-len(kv[1]), kv[0]))
|
||||
parts: list[str] = []
|
||||
for cat, group in sorted_candidates[:max_groups]:
|
||||
lines: list[str] = []
|
||||
for fact in group[:max_sources]:
|
||||
fid = fact.get("id", "?")
|
||||
conf = _coerce_source_confidence(fact)
|
||||
content = str(fact.get("content", ""))
|
||||
lines.append(f'- [{fid} | {conf:.2f}] "{content}"')
|
||||
shown = min(len(group), max_sources)
|
||||
parts.append(f'<consolidation_candidates category="{cat}" count="{shown}">\n' + "\n".join(lines) + "\n</consolidation_candidates>")
|
||||
return CONSOLIDATION_PROMPT.format(consolidation_groups="\n\n".join(parts), max_groups=max_groups)
|
||||
|
||||
|
||||
class MemoryUpdater:
|
||||
"""Updates memory using LLM based on conversation context."""
|
||||
|
||||
@ -542,11 +660,23 @@ class MemoryUpdater:
|
||||
config.staleness_age_days,
|
||||
)
|
||||
|
||||
# ── Build consolidation section ──
|
||||
consolidation_section = ""
|
||||
if config.consolidation_enabled:
|
||||
consolidation_candidates = _select_consolidation_candidates(current_memory, config)
|
||||
if consolidation_candidates:
|
||||
consolidation_section = _build_consolidation_section(
|
||||
consolidation_candidates,
|
||||
max_groups=config.consolidation_max_groups_per_cycle,
|
||||
max_sources=config.consolidation_max_sources,
|
||||
)
|
||||
|
||||
prompt = MEMORY_UPDATE_PROMPT.format(
|
||||
current_memory=json.dumps(current_memory, indent=2, ensure_ascii=False),
|
||||
conversation=conversation_text,
|
||||
correction_hint=correction_hint,
|
||||
staleness_review_section=staleness_section,
|
||||
consolidation_section=consolidation_section,
|
||||
)
|
||||
return current_memory, prompt
|
||||
|
||||
@ -852,6 +982,123 @@ class MemoryUpdater:
|
||||
reverse=True,
|
||||
)[: config.max_facts]
|
||||
|
||||
# ── Memory consolidation ──
|
||||
# Runs after the max_facts trim so source facts that were just evicted
|
||||
# (low confidence, pushed out by high-confidence newFacts) are absent
|
||||
# from fact_index and rejected by the existence guardrail — preventing
|
||||
# the only real data-loss scenario where sources are deleted but the
|
||||
# merged replacement is itself trimmed away. Because consolidation
|
||||
# always removes ≥2 facts and adds 1, running it after trim cannot push
|
||||
# the total above max_facts.
|
||||
# Gate on the feature flag at apply time so a config change that races
|
||||
# with a debounced update does not silently merge facts the operator
|
||||
# intended to keep separate.
|
||||
if config.consolidation_enabled:
|
||||
consolidation_decisions = update_data.get("factsToConsolidate", [])
|
||||
if isinstance(consolidation_decisions, list) and consolidation_decisions:
|
||||
fact_index = {f.get("id"): f for f in current_memory.get("facts", []) if isinstance(f, dict)}
|
||||
max_groups = config.consolidation_max_groups_per_cycle
|
||||
max_sources = config.consolidation_max_sources
|
||||
ids_consumed: set[str] = set()
|
||||
new_consolidated: list[dict[str, Any]] = []
|
||||
merge_count = 0
|
||||
|
||||
# Mirror the staleness-pass guardrail: build the set of IDs the LLM
|
||||
# was legitimately allowed to see as candidates (excludes protected
|
||||
# categories and categories below the threshold). Any LLM slip that
|
||||
# proposes a protected or ineligible fact ID is rejected here regardless
|
||||
# of model behaviour, matching how staleness intersects with
|
||||
# _select_stale_candidates before applying removals.
|
||||
allowed_source_ids = {f["id"] for group in _select_consolidation_candidates(current_memory, config).values() for f in group}
|
||||
|
||||
# Iterate all decisions and count successes rather than pre-slicing,
|
||||
# so guard failures on early decisions cannot silently starve valid
|
||||
# later ones from the configured merge budget.
|
||||
for decision in consolidation_decisions:
|
||||
if merge_count >= max_groups:
|
||||
break
|
||||
|
||||
source_ids = decision.get("sourceIds", [])
|
||||
consolidated = decision.get("consolidated", {})
|
||||
|
||||
# Guardrail: all source IDs must exist in the post-trim index,
|
||||
# must not already be consumed by an earlier merge this cycle,
|
||||
# and must be in allowed_source_ids — the set built from
|
||||
# _select_consolidation_candidates, which excludes categories in
|
||||
# staleness_protected_categories (default: "correction"). This
|
||||
# mirrors the staleness apply-time check and ensures explicit user
|
||||
# feedback is never silently merged away regardless of model behaviour.
|
||||
if any(sid in ids_consumed or sid not in fact_index or sid not in allowed_source_ids for sid in source_ids):
|
||||
continue
|
||||
# Guardrail: 2..max_sources per group
|
||||
if not (2 <= len(source_ids) <= max_sources):
|
||||
continue
|
||||
|
||||
content = consolidated.get("content", "")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
continue
|
||||
|
||||
source_confidences = [_coerce_source_confidence(fact_index[sid]) for sid in source_ids]
|
||||
# _coerce_source_confidence already clamps each value to [0, 1],
|
||||
# so max(source_confidences) ≤ 1.0 by contract.
|
||||
max_source_conf = max(source_confidences)
|
||||
|
||||
# Use the LLM's returned confidence, capped at the source maximum so
|
||||
# consolidation cannot inflate confidence. Clamp to [0, 1] first so
|
||||
# out-of-range values (e.g. 1.5) never leak even if the cap is later
|
||||
# relaxed. Falls back to max_source_conf when absent or malformed.
|
||||
raw_llm_conf = consolidated.get("confidence")
|
||||
if isinstance(raw_llm_conf, (int, float)) and not isinstance(raw_llm_conf, bool) and math.isfinite(float(raw_llm_conf)):
|
||||
fact_confidence = min(max(0.0, min(float(raw_llm_conf), 1.0)), max_source_conf)
|
||||
else:
|
||||
fact_confidence = max_source_conf
|
||||
|
||||
# Skip merges whose result would fall below the storage threshold —
|
||||
# same gate applied to newFacts, so consolidation never admits
|
||||
# facts that the normal ingestion path would reject.
|
||||
if fact_confidence < config.fact_confidence_threshold:
|
||||
continue
|
||||
|
||||
# Carry the newest source's createdAt so the staleness clock
|
||||
# reflects the age of the underlying information, not when
|
||||
# synthesis happened. consolidatedAt records the merge time
|
||||
# for audit without resetting staleness eligibility.
|
||||
# Use _parse_fact_datetime for crash-safe, timezone-aware comparison:
|
||||
# a numeric createdAt would make string max() raise TypeError, and
|
||||
# mixed Z/+00:00 formats sort wrong lexicographically.
|
||||
_fallback_dt = _parse_fact_datetime(now) or datetime.now(UTC)
|
||||
_source_dts = [_parse_fact_datetime(fact_index[sid].get("createdAt") or "") or _fallback_dt for sid in source_ids]
|
||||
_newest_dt = max(_source_dts)
|
||||
source_created_at = _newest_dt.isoformat().removesuffix("+00:00") + "Z"
|
||||
new_fact: dict[str, Any] = {
|
||||
"id": f"fact_{uuid.uuid4().hex[:8]}",
|
||||
"content": content.strip(),
|
||||
"category": consolidated.get("category", "context"),
|
||||
"confidence": fact_confidence,
|
||||
"createdAt": source_created_at,
|
||||
"consolidatedAt": now,
|
||||
"source": "consolidation",
|
||||
"consolidatedFrom": list(source_ids),
|
||||
}
|
||||
# Propagate sourceError from any source fact so correction
|
||||
# context (what went wrong and why) is not silently lost.
|
||||
source_errors = list(dict.fromkeys(e for sid in source_ids if isinstance((e := fact_index[sid].get("sourceError")), str) and e.strip()))
|
||||
if source_errors:
|
||||
new_fact["sourceError"] = "\n".join(source_errors)
|
||||
|
||||
ids_consumed.update(source_ids)
|
||||
new_consolidated.append(new_fact)
|
||||
merge_count += 1
|
||||
logger.info(
|
||||
"Consolidation merged %d facts into: %s",
|
||||
len(source_ids),
|
||||
content.strip()[:80],
|
||||
)
|
||||
|
||||
if ids_consumed:
|
||||
current_memory["facts"] = [f for f in current_memory.get("facts", []) if f.get("id") not in ids_consumed]
|
||||
current_memory["facts"].extend(new_consolidated)
|
||||
|
||||
return current_memory
|
||||
|
||||
|
||||
|
||||
@ -133,6 +133,38 @@ class MemoryConfig(BaseModel):
|
||||
description=("Fact categories exempt from staleness review. Correction facts represent explicit user feedback and should not be auto-pruned based on age alone."),
|
||||
)
|
||||
|
||||
# ── Memory consolidation ────────────────────────────────────────────
|
||||
consolidation_enabled: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Enable memory consolidation. When enabled, the LLM reviews "
|
||||
"fragmented fact categories during the normal memory-update call "
|
||||
"(same invocation — no extra API call) and decides whether groups "
|
||||
"of related facts can be synthesized into a single richer fact. "
|
||||
"Defaults to False because consolidation is lossy (source content "
|
||||
"is not preserved, only consolidatedFrom IDs). Opt in explicitly "
|
||||
"once the memory-file backup / audit story is in place."
|
||||
),
|
||||
)
|
||||
consolidation_min_facts: int = Field(
|
||||
default=8,
|
||||
ge=3,
|
||||
le=30,
|
||||
description=("Minimum number of facts in a single category to trigger consolidation review. Below this threshold the overhead of surfacing the group is not justified."),
|
||||
)
|
||||
consolidation_max_groups_per_cycle: int = Field(
|
||||
default=3,
|
||||
ge=1,
|
||||
le=10,
|
||||
description=("Maximum number of consolidation groups the LLM can merge in a single update cycle. Prevents over-consolidation."),
|
||||
)
|
||||
consolidation_max_sources: int = Field(
|
||||
default=8,
|
||||
ge=2,
|
||||
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."),
|
||||
)
|
||||
|
||||
|
||||
# Global configuration instance
|
||||
_memory_config: MemoryConfig = MemoryConfig()
|
||||
|
||||
1008
backend/tests/test_memory_consolidation.py
Normal file
1008
backend/tests/test_memory_consolidation.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -1438,6 +1438,26 @@ memory:
|
||||
staleness_protected_categories:
|
||||
- correction
|
||||
|
||||
# Memory consolidation: when a single category accumulates many fragmented
|
||||
# facts, the LLM reviews them during the normal memory-update call (same
|
||||
# invocation — no extra API call) and decides whether groups of related facts
|
||||
# can be synthesized into a single richer fact.
|
||||
# consolidation_enabled defaults to false because consolidation is lossy:
|
||||
# source fact content is permanently replaced by the LLM-synthesized fact
|
||||
# (only the source IDs are kept in consolidatedFrom). Enable explicitly once
|
||||
# you are comfortable with that trade-off.
|
||||
# consolidation_enabled - master switch (default: false)
|
||||
# consolidation_min_facts - minimum facts in a category to trigger
|
||||
# consolidation review (default: 8)
|
||||
# consolidation_max_groups_per_cycle - safety cap on merges per cycle
|
||||
# (default: 3)
|
||||
# consolidation_max_sources - max source facts per merge group;
|
||||
# prevents over-merging (default: 8)
|
||||
consolidation_enabled: false
|
||||
consolidation_min_facts: 8
|
||||
consolidation_max_groups_per_cycle: 3
|
||||
consolidation_max_sources: 8
|
||||
|
||||
# ============================================================================
|
||||
# Custom Agent Management API
|
||||
# ============================================================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user