From 338cd26dc244997b61980447065a558df19439b8 Mon Sep 17 00:00:00 2001 From: PerryLink Date: Sun, 25 Oct 2026 16:16:09 +0800 Subject: [PATCH] fix(memory): drop unusable numbers in stale extensions and consolidation confidence (#7442) * fix(memory): drop unusable numbers in stale extensions and consolidation confidence * fix(memory): log defaulted consolidation confidence and pin the int passthrough --- .../backends/deermem/deermem/core/updater.py | 25 ++++++- .../tests/test_memory_update_normalization.py | 72 +++++++++++++++++++ 2 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 backend/tests/test_memory_update_normalization.py diff --git a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py index c813648ce5..4ff7d0584e 100644 --- a/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py +++ b/backend/packages/harness/deerflow/agents/memory/backends/deermem/deermem/core/updater.py @@ -183,6 +183,14 @@ def _log_dropped_confidence(raw: object) -> None: ) +def _log_defaulted_consolidation_confidence(raw: object) -> None: + """Make a systematically out-of-range consolidation confidence visible.""" + logger.warning( + "Defaulting a consolidation with out-of-range confidence %s to 0.9; expected 0.0-1.0. A model emitting a percentage scale would lose the confidence signal.", + repr(raw)[:80], + ) + + def _normalize_memory_update_fact(fact: Any) -> dict[str, Any] | None: """Normalize a single fact entry from a model-produced memory update.""" if not isinstance(fact, dict): @@ -363,7 +371,10 @@ def _normalize_memory_update_data(update_data: dict[str, Any]) -> dict[str, Any] # apply path never silently writes a zero-delta extension. raw_extend = entry.get("extend_by_days") if isinstance(raw_extend, (int, float)) and not isinstance(raw_extend, bool): - extend_by = int(raw_extend) + # int() raises on a non-finite float (a JSON number such as 1e400 parses + # to inf), which would fail the whole batch; drop it like any other + # unusable value. An int is kept as-is, including values beyond float range. + extend_by = int(raw_extend) if isinstance(raw_extend, int) or math.isfinite(raw_extend) else 0 if extend_by > 0: reason = entry.get("reason", "") normalized_stale_extensions.append( @@ -402,8 +413,16 @@ def _normalize_memory_update_data(update_data: dict[str, Any]) -> dict[str, Any] 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 + # float() raises on an unbounded JSON integer such as 10**400, which + # would fail the whole batch; fall back like any other invalid value. + try: + _f = float(_raw_conf) + except OverflowError: + _f = math.inf + if not math.isfinite(_f): + _log_defaulted_consolidation_confidence(_raw_conf) + _f = 0.9 + _norm_conf = _f _raw_cat = consolidated.get("category") _norm_cat = _raw_cat.strip() if isinstance(_raw_cat, str) and _raw_cat.strip() else "context" normalized_consolidation.append( diff --git a/backend/tests/test_memory_update_normalization.py b/backend/tests/test_memory_update_normalization.py new file mode 100644 index 0000000000..80368cd653 --- /dev/null +++ b/backend/tests/test_memory_update_normalization.py @@ -0,0 +1,72 @@ +"""Tests for non-finite and out-of-range numbers in memory update responses. + +A model can answer with a JSON number outside float range (``1e400`` parses to +``inf``) or with ``NaN``, and an unbounded integer such as ``10**400`` raises +``OverflowError`` in ``float()``. These optional fields are dropped or defaulted +per entry, as ``_normalize_memory_update_fact`` already does, instead of failing +the whole batch and losing the valid facts in the same response. +""" + +import json +import logging + +from deerflow.agents.memory.backends.deermem.deermem.core.updater import _parse_memory_update_response + +_VALID_FACT = { + "content": "User prefers dark mode", + "category": "preference", + "confidence": 0.9, + "scope": "user", + "durability": "durable", + "authority": "descriptive", +} + + +def _response(extra): + return '{"user":{},"history":{},"newFacts":[' + json.dumps(_VALID_FACT) + "]," + extra + "}" + + +def test_infinite_extend_by_days_is_dropped(): + # 1e400 is outside float range, so json parses it to inf and int(inf) raises. + parsed = _parse_memory_update_response(_response('"staleFactsToExtend":[{"id":"fact_1","extend_by_days":1e400}]')) + + assert len(parsed["newFacts"]) == 1 + assert parsed["staleFactsToExtend"] == [] + + +def test_nan_extend_by_days_is_dropped(): + parsed = _parse_memory_update_response(_response('"staleFactsToExtend":[{"id":"fact_1","extend_by_days":NaN}]')) + + assert len(parsed["newFacts"]) == 1 + assert parsed["staleFactsToExtend"] == [] + + +def test_unbounded_integer_extend_by_days_is_kept(): + # An integer literal beyond float range must survive as an int: the apply path + # bounds it with min(days_since + extend_by, staleness_max_extension_days), which + # works on arbitrary-precision ints but would raise OverflowError on int(float()). + huge = "1" + "0" * 400 + parsed = _parse_memory_update_response(_response('"staleFactsToExtend":[{"id":"fact_1","extend_by_days":' + huge + "}]")) + + assert len(parsed["newFacts"]) == 1 + assert parsed["staleFactsToExtend"][0]["extend_by_days"] == 10**400 + + +def test_unbounded_integer_consolidation_confidence_defaults(caplog): + # float(10**400) raises OverflowError; the entry keeps its default confidence + # and the default is logged so a systematically mis-scaled model stays visible. + with caplog.at_level(logging.WARNING): + parsed = _parse_memory_update_response(_response('"factsToConsolidate":[{"sourceIds":["fact_a","fact_b"],"consolidated":{"content":"Merged fact","category":"knowledge","confidence":' + "1" + "0" * 400 + "}}]")) + + assert len(parsed["newFacts"]) == 1 + assert parsed["factsToConsolidate"][0]["consolidated"]["confidence"] == 0.9 + assert "out-of-range confidence" in caplog.text + + +def test_finite_values_are_kept(): + parsed = _parse_memory_update_response( + _response('"staleFactsToExtend":[{"id":"fact_1","extend_by_days":30}],"factsToConsolidate":[{"sourceIds":["fact_a","fact_b"],"consolidated":{"content":"Merged fact","category":"knowledge","confidence":0.7}}]') + ) + + assert parsed["staleFactsToExtend"][0]["extend_by_days"] == 30 + assert parsed["factsToConsolidate"][0]["consolidated"]["confidence"] == 0.7