fix(memory): coerce null source.confidence so it no longer blocks memory updates (#4074)

* fix(memory): coerce null confidence when ranking stale facts

_build_staleness_section and the per-cycle removal cap in _apply_updates
used fact.get("confidence", 0.0/0), which only defaults when the key is
absent. A fact whose confidence is explicitly null (or otherwise malformed)
returned None, breaking the numeric sort/format. Use
_coerce_source_confidence, which normalizes null/malformed values and clamps
to [0, 1], so null-confidence facts no longer block staleness handling.

* ci: retrigger cancelled CI workflow

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
黄云龙 2026-07-12 23:22:09 +08:00 committed by GitHub
parent 6af6cfc70b
commit 490deeb931
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 68 additions and 2 deletions

View File

@ -592,7 +592,7 @@ def _build_staleness_section(
for fact in stale_candidates:
fid = fact.get("id", "?")
cat = html.escape(str(fact.get("category", "context")).strip() or "context")
conf = fact.get("confidence", 0.0)
conf = _coerce_source_confidence(fact)
created_raw = fact.get("createdAt", "")
created_short = created_raw[:10] if isinstance(created_raw, str) and len(created_raw) >= 10 else created_raw
content = html.escape(str(fact.get("content", "")))
@ -1038,7 +1038,7 @@ class MemoryUpdater:
max_stale = config.staleness_max_removals_per_cycle
if len(stale_ids_to_remove) > max_stale:
stale_facts = [f for f in current_memory.get("facts", []) if f.get("id") in stale_ids_to_remove]
stale_facts.sort(key=lambda f: f.get("confidence", 0))
stale_facts.sort(key=_coerce_source_confidence)
stale_ids_to_remove = {f["id"] for f in stale_facts[:max_stale]}
current_memory["facts"] = [f for f in current_memory.get("facts", []) if f.get("id") not in stale_ids_to_remove]

View File

@ -5,6 +5,8 @@ from unittest.mock import AsyncMock, MagicMock, patch
from deerflow.agents.memory.prompt import format_conversation_for_update
from deerflow.agents.memory.updater import (
MemoryUpdater,
_build_staleness_section,
_coerce_source_confidence,
_extract_text,
_parse_memory_update_response,
clear_memory_data,
@ -1446,6 +1448,70 @@ class TestSyncUpdateBindsTraceContextVar:
assert get_current_trace_id() == "outer-trace"
class TestNullConfidenceDoesNotBlockUpdates:
"""A fact persisted with ``"confidence": null`` (corrupted or hand-edited
memory file) must not crash confidence-sensitive code paths.
``dict.get("confidence", 0.0)`` returns the stored ``None`` when the key is
present, which then propagates into ``f"{conf:.2f}"`` formatting and into
``list.sort`` comparisons and raises ``TypeError``. ``_coerce_source_confidence``
guards both call sites.
"""
def test_build_staleness_section_handles_null_confidence(self) -> None:
stale = [
{
"id": "fact_null",
"content": "User prefers concise answers",
"category": "preference",
"confidence": None,
"createdAt": "2000-01-01T00:00:00Z",
}
]
# Must not raise TypeError on ``f"{None:.2f}"``.
section = _build_staleness_section(stale, age_days=90)
assert isinstance(section, str)
assert "fact_null" in section
def test_apply_updates_staleness_sort_handles_null_confidence(self) -> None:
updater = MemoryUpdater()
aged = "2000-01-01T00:00:00Z" # far older than staleness_age_days
facts = [
{"id": "f_null", "content": "a", "category": "context", "confidence": None, "createdAt": aged},
{"id": "f_high", "content": "b", "category": "context", "confidence": 0.9, "createdAt": aged},
{"id": "f_low", "content": "c", "category": "context", "confidence": 0.2, "createdAt": aged},
]
memory = _make_memory(facts)
update_data = {
"user": {},
"history": {},
"newFacts": [],
"factsToRemove": [],
# LLM asks to remove all three; the per-cycle cap keeps only the
# lowest-confidence one, which forces the sort over null confidence.
"staleFactsToRemove": [{"id": "f_null"}, {"id": "f_high"}, {"id": "f_low"}],
}
with patch(
"deerflow.agents.memory.updater.get_memory_config",
return_value=_memory_config(staleness_max_removals_per_cycle=1, staleness_age_days=90),
):
# Must not raise TypeError comparing None with floats during sort.
result = updater._apply_updates(memory, update_data)
remaining_ids = {fact["id"] for fact in result["facts"]}
# Lowest confidence (0.2) is removed first; null coerces to 0.5, so it stays.
assert "f_low" not in remaining_ids
assert remaining_ids == {"f_null", "f_high"}
def test_coerce_source_confidence_defaults_null_to_midpoint(self) -> None:
assert _coerce_source_confidence({"confidence": None}) == 0.5
assert _coerce_source_confidence({}) == 0.5
assert _coerce_source_confidence({"confidence": 0.83}) == 0.83
class TestParseMemoryUpdateFactsToRemoveGate:
"""``factsToRemove`` is optional in the memory-update JSON acceptance gate.