#3176·CowAgent

[Bug] Memory index silently drops results: knowledge/ writes never mark the index dirty, and keyword-only hits are scaled below min_score

Author: NBSW002Created Sep 17, 2026Updated Sep 17, 2026

Environment

  • Version: v2.1.9 (2.1.9-9-g3bf04290)
  • OS: Windows 11
  • Python: 3.12.13
  • Install: source
  • Model & channel: deepseek-flash, web

What happened?

Two independent paths in agent/memory silently return empty retrieval results — no exception, no log, the caller just gets []. Both are normal runtime states, not edge cases.


Problem 1 — knowledge/ writes (and any external write) never trigger an index sync

Writing a file into knowledge/*.md — or any write that bypasses the write/edit tools — does not mark the memory index dirty, so search() never re-syncs and the new content stays invisible until a process restart.

Evidence (a)search() only syncs when _dirty is set (agent/memory/manager.py:124-126):

# Sync if needed
if self.config.sync_on_search and self._dirty:
    await self.sync()

Evidence (b) — the dirty flag is set under a condition that only matches memory/ (agent/tools/write/write.py:97-99):

# Auto-sync to memory database if this is a memory file
if self.memory_manager and 'memory/' in path:
    self.memory_manager.mark_dirty()

and the same in agent/tools/edit/edit.py:219-222:

# Notify memory manager if file is in memory directory
if self.memory_manager and "memory/" in path:
    self.memory_manager.mark_dirty()

A knowledge/xxx.md path does not contain the substring memory/, so mark_dirty() is never called.

Evidence (c)mark_dirty() has only 4 call sites in the whole repo:

agent/knowledge/service.py:126      manager.mark_dirty()
agent/tools/edit/edit.py:222        condition: "memory/" in path
agent/tools/write/write.py:99       condition: "memory/" in path
channel/web/web_channel.py:9231     mm.mark_dirty()

Any writer that does not go through these entry points (external scripts, other processes editing files directly) is exempt from index invalidation too.

Evidence (d)_dirty is initialized to False (agent/memory/manager.py:79) and sync() has no mtime/hash-based detection of "the file changed since last sync" at the point of triggering. So files modified while the process was down are never re-indexed after a restart either.

Real-world confirmation — querying the live index DB (<workspace>/memory/long-term/index.db) of an actual running workspace:

files table:
  memory     | MEMORY.md                 indexed
  memory     | memory\2026-09-16.md      indexed
  memory     | memory\2026-09-17.md      <-- exists on disk, NOT in index
  knowledge  | knowledge\*.md            indexed

SELECT COUNT(*) FROM chunks WHERE path LIKE '%2026-09-17%';
  -> 0

That day's diary file exists on disk and has zero chunks in the index.

Trigger chain

write knowledge/xxx.md
  -> 'memory/' in path == False
  -> mark_dirty() not called
  -> self._dirty stays False
  -> `if sync_on_search and self._dirty` is False inside search()
  -> sync() never runs -> new file never indexed
  -> search returns nothing

Problem 2 — in keyword-only mode, every keyword-only hit is filtered out

When no embedding provider is available, memory degrades to keyword search only — a supported, automatically-entered state (manager.py:59-63 prints No embedding provider; memory will use keyword search only). In that mode, all hits are silently dropped.

Reproduced:

keyword raw score     : [0.3]
score after fusion    : [0.09]
manager.search returns: 0 results   (min_score=0.1)

The same corpus with an embedding provider returns the hit fine (score=0.7900).

Evidence (a) — fusion weights an absent vector score as zero (manager.py:563-568):

combined_score = (
    vector_weight * entry['vector_score'] +      # 0.7 * 0.0
    keyword_weight * entry['keyword_score']       # 0.3 * 0.3
)
# = 0.09

Evidence (b) — the keyword score has a floor of 0.3 (agent/memory/storage.py:1175-1180):

if rank is None:
    return 0.0
# Add a floor of 0.3 so any FTS5 match always exceeds typical
# min_score thresholds (default 0.1).  Small-corpus ranks close to
# 0 would otherwise produce score≈0 and be filtered out downstream.
return 0.3 + 0.69 * (abs(rank) / (1.0 + abs(rank)))   # range [0.3, 0.99)

Evidence (c) — the filter (manager.py:172-174):

filtered = [r for r in merged if r.score >= min_score]

Evidence (d) — defaults (agent/memory/config.py:43,46-47): min_score = 0.1, vector_weight = 0.7, keyword_weight = 0.3.

Root cause — an implicit coupling whose inequality goes the wrong way:

keyword_weight(0.3) * bm25_floor(0.3) = 0.09  <  min_score(0.1)

min_score is higher than the best score a keyword-only hit can achieve. So any chunk matched only by keywords is unconditionally filtered out — keyword-only retrieval is effectively dead.

Note the contradiction with the comment at storage.py:1177-1179: the floor was added precisely "so any FTS5 match always exceeds typical min_score thresholds (default 0.1)". That assumption held for the raw keyword score; once it is multiplied by keyword_weight = 0.3, 0.3 * 0.3 = 0.09 drops back below the threshold. The comment's guarantee silently stopped holding when weighted fusion was introduced.

These three constants live in config.py / storage.py / config.py, with no assertion or test tying them together — any future tweak re-triggers or reshapes the failure silently.


Reproduction

Zero-dependency script (standard library + project modules only, works in a temp dir, does not touch the real index):

import asyncio, tempfile
from pathlib import Path
from agent.memory.config import MemoryConfig
from agent.memory.manager import MemoryManager

async def main():
    tmp = Path(tempfile.mkdtemp(prefix="repro_"))
    cfg = MemoryConfig(workspace_root=str(tmp))

    # ---- Problem 1: writing knowledge/ never marks the index dirty ----
    mm = MemoryManager(config=cfg)
    mm._init_workspace()
    (tmp / "knowledge").mkdir(exist_ok=True)
    (tmp / "memory").mkdir(exist_ok=True)

    kf = tmp / "knowledge" / "note.md"
    kf.write_text("# note\nGLACIERQUARTZ7731\n", encoding="utf-8")
    path_rel = str(kf.relative_to(tmp)).replace("\\", "/")
    if "memory/" in path_rel:      # the real condition in write.py:98
        mm.mark_dirty()
    print("P1 hits:", len(await mm.search("GLACIERQUARTZ7731")))   # -> 0

    # ---- Problem 2: keyword-only hits fall below min_score ----
    kf2 = tmp / "knowledge" / "b3.md"
    kf2.write_text("# b3\nSILVERLOTUS4410\n", encoding="utf-8")
    await mm.sync()                # force sync, isolating from P1
    raw = mm.storage.search_keyword(query="SILVERLOTUS4410", user_id=None,
                                    scopes=["shared"], limit=5)
    fused = mm._merge_results([], raw, cfg.vector_weight, cfg.keyword_weight)
    print("P2 raw:", [round(r.score, 4) for r in raw])       # -> [0.3]
    print("P2 fused:", [round(r.score, 4) for r in fused])   # -> [0.09]
    print("P2 hits:", len(await mm.search("SILVERLOTUS4410")))  # -> 0

asyncio.run(main())

Note: the snippet deliberately pre-creates a local knowledge/ directory, because otherwise sync() would hit the unrelated crash in #3175 and mask both results. The two problems also interact: verifying Problem 1 requires bypassing Problem 2 (e.g. by supplying an embedding provider), and verifying Problem 2 requires an explicit sync() to bypass Problem 1.


Suggested fixes

Problem 1

  • Minimal: extend the condition in write.py:98 / edit.py:220 from 'memory/' in path to cover knowledge/ as well (or reuse MemoryConfig's directory prefixes).
  • More robust (preferred): stop relying on the in-memory flag — have search() always attempt an incremental sync, using the existing mtime/hash comparison to skip unchanged files.
  • Fallback: initialize _dirty = True at startup so changes made while the process was down are picked up.
  • Optional: a filesystem watcher (watchdog) — best UX for hand-edited memory files, at higher complexity.

Problem 2

  • Minimal and semantically correct — normalize by the weights that actually participated: divide by the sum of active weights instead of treating a missing channel as a zero score. Then a keyword-only hit scores (0.3 * 0.3) / 0.3 = 0.3, correctly clearing min_score, and matching the original intent of the storage.py floor comment:
def _fused(v_score, k_score, vw, kw, has_v, has_k):
    total = vw * has_v + kw * has_k
    if total <= 0:
        return 0.0
    return (vw * v_score * has_v + kw * k_score * has_k) / total
  • Alternatively, lower min_score below keyword_weight * bm25_floor (e.g. 0.05) — but this only masks the problem and admits more noise; not recommended on its own.
  • Regardless of the approach, please make the implicit constraint explicit with a startup assertion or a dedicated min_score for the keyword-only branch, so future tuning cannot silently reintroduce this.

Note on scope

I originally suspected knowledge/ was not indexed at all. That was wrong — querying the DB showed it is indexed (93/101 chunks). I am reporting the two problems above, both of which I reproduced deterministically. A separate crash found along the way was filed as #3175 since it has an independent root cause.

I'm happy to open a PR for either problem. For Problem 1 I would suggest the minimal fix plus the startup fallback; for Problem 2 the normalization approach. Please let me know your preference.


中文简述

agent/memory两条正常的运行时路径会静默返回空检索结果(无异常、无日志):

问题 1:knowledge/ 写入(及任何外部写入)不触发索引同步 write.py:98 / edit.py:220 的置脏条件写死为 'memory/' in path,而 knowledge/xxx.md 不含该子串 → 永不 mark_dirty()search() 不触发 sync()新内容直到进程重启都对检索不可见。全仓库 mark_dirty() 仅 4 个调用点,任何绕过 write/edit 工具的外部写入同样不置脏;且 _dirty 初值为 False停机期间改动的文件重启后也不会重新索引。真实佐证:磁盘上存在 memory/2026-09-17.md,索引里 0 个 chunk。

问题 2:纯关键词模式下,所有关键词命中被静默丢弃 融合公式把缺失的向量分当作 0:0.3 × 0.3 = 0.09,而 min_score = 0.1 —— 0.3 × keyword_weight 恒小于阈值。无 embedding provider(manager.py:59-63 明确支持的降级路径)时,仅关键词命中的结果全部被丢,纯关键词检索实际完全失效。storage.py:1177-1179 的注释说加 0.3 地板是为了"超过 min_score",但加权融合引入后该保证已失效且无人察觉。

三个常量分布在 config.py / storage.py / config.py,无任何断言保护这个不等式。

说明:我最初怀疑 knowledge/ 没被索引,实测证明该假设错误(93/101 chunks 来自 knowledge/)。以上两个问题均为我确定性复现的真缺陷。另有一个根因独立的崩溃已单独提为 #3175。

两份问题我都可以提 PR,请告知倾向的修复方案。