From 490deeb931431c5f1a58ebfda2d130c694caa97d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BA=91=E9=BE=99?= <76432572+nankingjing@users.noreply.github.com> Date: Sun, 12 Jul 2026 23:22:09 +0800 Subject: [PATCH] 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 --- .../harness/deerflow/agents/memory/updater.py | 4 +- backend/tests/test_memory_updater.py | 66 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/backend/packages/harness/deerflow/agents/memory/updater.py b/backend/packages/harness/deerflow/agents/memory/updater.py index 86300d419..73b8b1ccc 100644 --- a/backend/packages/harness/deerflow/agents/memory/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/updater.py @@ -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] diff --git a/backend/tests/test_memory_updater.py b/backend/tests/test_memory_updater.py index 702605868..4c065ba0d 100644 --- a/backend/tests/test_memory_updater.py +++ b/backend/tests/test_memory_updater.py @@ -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.