Compare commits

...
Author SHA1 Message Date
alvinfeiandCursor 1f4bdbf563 fix(coding): default-off search chain_block (diff) injection
Medium/heavy coding users pay large prompt tokens for the version-evolution
diff block; make MEMORY_CODING_CHAIN_BLOCK_ENABLED opt-in (default false).
Write-side observe is unchanged; HCM_BLOCK_OFF remains an advanced kill switch.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 14:40:13 +08:00
14 changed files with 117 additions and 13 deletions

No files matched your search

+1
View File
@@ -86,6 +86,7 @@ MEMORY_VECTOR_STORE=chroma
# MEMORY_CODING_DB_PATH= # coding_memory.db 路径(默认 {DATA_DIR}/data/coding_memory.db) # MEMORY_CODING_DB_PATH= # coding_memory.db 路径(默认 {DATA_DIR}/data/coding_memory.db)
# MEMORY_CODING_TOOL_RESULT_MAX_BYTES=2048 # 单条 tool_result 截断字节上限 # MEMORY_CODING_TOOL_RESULT_MAX_BYTES=2048 # 单条 tool_result 截断字节上限
# MEMORY_CODING_VERBATIM_FALLBACK_ENABLED=false # 模型无结果时是否保存原文 fact;默认关,避免 tool trace 外泄 # MEMORY_CODING_VERBATIM_FALLBACK_ENABLED=false # 模型无结果时是否保存原文 fact;默认关,避免 tool trace 外泄
# MEMORY_CODING_CHAIN_BLOCK_ENABLED=false # search 是否注入版本演进链块(diff);默认关,省 token
# MEMORY_EXTRACT_HISTORY_TURNS=5 # 除本次 write 外,额外注入 extractor 的历史 user-turn 数 # MEMORY_EXTRACT_HISTORY_TURNS=5 # 除本次 write 外,额外注入 extractor 的历史 user-turn 数
# ── 写入流程开关 ── # ── 写入流程开关 ──
+4
View File
@@ -14,6 +14,10 @@ Hy Memory and MemHub are versioned and released independently.
## Unreleased ## Unreleased
- Coding search no longer injects the version-evolution diff block
(`chain_block`) by default. Enable with
`MEMORY_CODING_CHAIN_BLOCK_ENABLED=true` or
`CodingConfig.chain_block_enabled=True` (write-side observe unchanged).
- Fixed governed-memory revisions and repeated evidence links failing when the - Fixed governed-memory revisions and repeated evidence links failing when the
visibility dirty-marker trigger encounters the same memory twice. Startup visibility dirty-marker trigger encounters the same memory twice. Startup
now replaces the affected legacy trigger without changing schema versions now replaces the affected legacy trigger without changing schema versions
+1 -1
View File
@@ -153,7 +153,7 @@ result = client.search(
result["facts"] # fact layer result["facts"] # fact layer
result["experiences"] # experience layer result["experiences"] # experience layer
result["chain_block"] # coding only: version-evolution context result["chain_block"] # coding only: version-evolution context (empty unless MEMORY_CODING_CHAIN_BLOCK_ENABLED)
``` ```
### 5. Routing, Source Isolation & Advanced APIs ### 5. Routing, Source Isolation & Advanced APIs
+1 -1
View File
@@ -153,7 +153,7 @@ result = client.search(
result["facts"] # 事实层 result["facts"] # 事实层
result["experiences"] # 经验层 result["experiences"] # 经验层
result["chain_block"] # 仅 coding:版本演进上下文 result["chain_block"] # 仅 coding:版本演进上下文(默认空;需 MEMORY_CODING_CHAIN_BLOCK_ENABLED)
``` ```
### 5. 自动路由、来源过滤与进阶 API ### 5. 自动路由、来源过滤与进阶 API
+1
View File
@@ -197,6 +197,7 @@ SDK 不设置 Rerank Search 并发上限。开启后模型在 `HyMemoryClient`
| `MEMORY_HISTORY_DB_PATH` | `{DATA_DIR}/data/history.db` | History SQLite 路径 | | `MEMORY_HISTORY_DB_PATH` | `{DATA_DIR}/data/history.db` | History SQLite 路径 |
| `MEMORY_HISTORY_RECORD_SEARCHES` | `false` | 是否记录 Search 操作 | | `MEMORY_HISTORY_RECORD_SEARCHES` | `false` | 是否记录 Search 操作 |
| `MEMORY_CODING_CHAIN_REPO_ROOT` | — | coding 版本链读取本地 Git diff 的仓库根目录 | | `MEMORY_CODING_CHAIN_REPO_ROOT` | — | coding 版本链读取本地 Git diff 的仓库根目录 |
| `MEMORY_CODING_CHAIN_BLOCK_ENABLED` | `false` | search(scene=coding) 是否注入版本演进链块(diff / `chain_block`);默认关 |
## HTTP Server ## HTTP Server
+2 -1
View File
@@ -78,7 +78,8 @@ default 多-query 超过 `MEMORY_QUERY_REWRITE_MAX_CHARS` 时不改写:Coding
Coding 关闭则直接使用最后一条 query。 Coding 关闭则直接使用最后一条 query。
路由到 `working` / `coding` 后,Search 读取独立生产力记忆库,返回 `facts` / 路由到 `working` / `coding` 后,Search 读取独立生产力记忆库,返回 `facts` /
`experiences`,不使用 chat 三通道结构。`coding` 还返回 `chain_block` / `chain_used`。 `experiences`,不使用 chat 三通道结构。`coding` 还返回 `chain_block` / `chain_used`
(默认空;需 `MEMORY_CODING_CHAIN_BLOCK_ENABLED` 才注入 diff 块)。
## Chat 成功返回结构 ## Chat 成功返回结构
+6 -2
View File
@@ -168,9 +168,11 @@ working/coding 搜索返回:
`limit` 分别限制 facts 与 experiences,因此两层最多各返回 `limit` 条。 `limit` 分别限制 facts 与 experiences,因此两层最多各返回 `limit` 条。
`scene="coding"` 额外返回 `chain_block` 和 `chain_used`: `scene="coding"` 额外返回 `chain_block` 和 `chain_used` 字段(键始终存在;默认内容为空):
- `chain_block` 是与已召回 fact 对齐的版本演进上下文;无法挂链时为 `""`。 - 注入 diff / 版本演进块需显式开启 `MEMORY_CODING_CHAIN_BLOCK_ENABLED=true`
(或 `CodingConfig.chain_block_enabled=True`)。默认关闭,以节约 prompt token。
- 开启后,`chain_block` 是与已召回 fact 对齐的版本演进上下文;无法挂链时为 `""`。
- `chain_used` 是已经进入 chain block 的 `facts` 下标。 - `chain_used` 是已经进入 chain block 的 `facts` 下标。
- SDK 不会从 `facts` 删除这些条目。调用方若要拼成“chain block + 剩余 facts”,需自行按 `chain_used` 过滤。 - SDK 不会从 `facts` 删除这些条目。调用方若要拼成“chain block + 剩余 facts”,需自行按 `chain_used` 过滤。
@@ -222,10 +224,12 @@ working/coding 搜索返回:
| `MEMORY_CODING_DB_PATH` | `{DATA_DIR}/data/coding_memory.db` | working 基础 SQLite 路径;coding/trace 路径由此派生 | | `MEMORY_CODING_DB_PATH` | `{DATA_DIR}/data/coding_memory.db` | working 基础 SQLite 路径;coding/trace 路径由此派生 |
| `MEMORY_CODING_TOOL_RESULT_MAX_BYTES` | `2048` | 单条 tool result 的字节上限 | | `MEMORY_CODING_TOOL_RESULT_MAX_BYTES` | `2048` | 单条 tool result 的字节上限 |
| `MEMORY_CODING_VERBATIM_FALLBACK_ENABLED` | `false` | 模型无结果时是否保存原文 fact;默认关闭,避免 assistant/tool trace 直接展示 | | `MEMORY_CODING_VERBATIM_FALLBACK_ENABLED` | `false` | 模型无结果时是否保存原文 fact;默认关闭,避免 assistant/tool trace 直接展示 |
| `MEMORY_CODING_CHAIN_BLOCK_ENABLED` | `false` | search 是否注入版本演进链块(`chain_block` / diff);默认关闭(opt-in) |
| `MEMORY_EXTRACT_HISTORY_TURNS` | `5` | 回读的历史 user-turn 数;当前 prompt 最终最多使用 5 | | `MEMORY_EXTRACT_HISTORY_TURNS` | `5` | 回读的历史 user-turn 数;当前 prompt 最终最多使用 5 |
| `MEMORY_CODING_CHAIN_REPO_ROOT` | 空 | coding 版本链读取本地 Git diff 的仓库路径 | | `MEMORY_CODING_CHAIN_REPO_ROOT` | 空 | coding 版本链读取本地 Git diff 的仓库路径 |
`HCM_*` 变量属于版本链的高级调优项,不是稳定的公共配置契约;普通接入不应依赖其默认值。 `HCM_*` 变量属于版本链的高级调优项,不是稳定的公共配置契约;普通接入不应依赖其默认值。
其中 `HCM_BLOCK_OFF=1` 可在已开启 `MEMORY_CODING_CHAIN_BLOCK_ENABLED` 时做消融(完全不出块)。
## 8. 定向验证 ## 8. 定向验证
+2 -1
View File
@@ -210,7 +210,8 @@ result = client.search(
``` ```
`working` / `coding` 路径返回 `scene="productivity"`、`facts`、`experiences`,不使用 `working` / `coding` 路径返回 `scene="productivity"`、`facts`、`experiences`,不使用
chat 的三桶结构;`coding` 还会返回 `chain_block` 和 `chain_used`。 chat 的三桶结构;`coding` 还会返回 `chain_block` 和 `chain_used` 字段(默认空;
需 `MEMORY_CODING_CHAIN_BLOCK_ENABLED=true` 才注入 diff 块)。
#### `search_mode` #### `search_mode`
+18 -2
View File
@@ -1192,6 +1192,19 @@ class HyMemoryClient:
logger.warning("[coding] chain observe failed: %s", e) logger.warning("[coding] chain observe failed: %s", e)
return 0 return 0
def _chain_block_enabled(self) -> bool:
"""search 是否注入 coding 版本演进链块(diff / chain_block)。
公共开关:CodingConfig.chain_block_enabled /
MEMORY_CODING_CHAIN_BLOCK_ENABLED(默认 False,opt-in)。
无 _config 时按关闭处理。高级消融开关 HCM_BLOCK_OFF=1 仍在
chain_store.decorate 内生效(仅当本开关已开启时才有意义)。
"""
coding = getattr(getattr(self, "_config", None), "coding", None)
if coding is None:
return False
return bool(getattr(coding, "chain_block_enabled", False))
def _decorate_with_chain(self, facts, instruction: str = "", scope=None, def _decorate_with_chain(self, facts, instruction: str = "", scope=None,
user_id=None, workspace_id=None): user_id=None, workspace_id=None):
"""读取侧:给向量层召回的 facts 挂上版本演进链块。 """读取侧:给向量层召回的 facts 挂上版本演进链块。
@@ -1204,11 +1217,14 @@ class HyMemoryClient:
facts 顺序不重排 —— top-k 是向量层的事,这层只负责"每条 fact 后面跟什么"。 facts 顺序不重排 —— top-k 是向量层的事,这层只负责"每条 fact 后面跟什么"。
best-effort:失败返回 ("", []),退化成纯向量双层(= working 的行为)。 best-effort:失败返回 ("", []),退化成纯向量双层(= working 的行为)。
默认不注入(见 _chain_block_enabled);需显式开启 chain_block_enabled。
""" """
if not facts: if not facts:
return "", [] return "", []
# 走类而不是 self:_chain_owner 是 staticmethod,而这两个方法会被测试绑到裸 # 走类而不是 self:测试常把本方法绑到裸 SimpleNamespace 上,self 上未必有
# SimpleNamespace 上调(同 _scene_variant 那处),self 上没有这个属性。 # 同名 bound method;_chain_owner 同理。
if not HyMemoryClient._chain_block_enabled(self):
return "", []
store = self._get_chain_store( store = self._get_chain_store(
HyMemoryClient._chain_owner(user_id, workspace_id)) HyMemoryClient._chain_owner(user_id, workspace_id))
if store is None: if store is None:
+3 -1
View File
@@ -647,7 +647,9 @@ class HyCodeMem:
top-k,但它们的 diff 对当前工作区不可能有用,因此挂链前必须做同仓过滤。 top-k,但它们的 diff 对当前工作区不可能有用,因此挂链前必须做同仓过滤。
""" """
facts = facts or [] facts = facts or []
# 开关: HCM_BLOCK_OFF=1 → 完全不出块, 也不从 bullet 删任何 fact。 # 高级消融开关: HCM_BLOCK_OFF=1 → 完全不出块, 也不从 bullet 删任何 fact。
# 公共 opt-in 见 CodingConfig.chain_block_enabled /
# MEMORY_CODING_CHAIN_BLOCK_ENABLED(默认关;客户端在调用 decorate 前拦截)。
if os.environ.get("HCM_BLOCK_OFF") == "1": if os.environ.get("HCM_BLOCK_OFF") == "1":
return "", [] return "", []
picked, used = [], [] picked, used = [], []
+7
View File
@@ -1136,6 +1136,9 @@ class CodingConfig:
# LLM 失败或没有抽出 fact 时,是否把对话原文存成 working fact。 # LLM 失败或没有抽出 fact 时,是否把对话原文存成 working fact。
# 默认关闭,避免 assistant/tool 轨迹以 statement 形式直接暴露给消费方。 # 默认关闭,避免 assistant/tool 轨迹以 statement 形式直接暴露给消费方。
verbatim_fallback_enabled: bool = None verbatim_fallback_enabled: bool = None
# search(scene="coding") 是否注入版本演进链块(diff / chain_block)。
# 默认关闭(opt-in):中重度 coding 用户可省下大量 prompt token;写入侧仍可 observe。
chain_block_enabled: bool = None
def __post_init__(self): def __post_init__(self):
if self.enable is None: if self.enable is None:
@@ -1153,6 +1156,10 @@ class CodingConfig:
self.verbatim_fallback_enabled = _get_env_bool( self.verbatim_fallback_enabled = _get_env_bool(
"MEMORY_CODING_VERBATIM_FALLBACK_ENABLED", False "MEMORY_CODING_VERBATIM_FALLBACK_ENABLED", False
) )
if self.chain_block_enabled is None:
self.chain_block_enabled = _get_env_bool(
"MEMORY_CODING_CHAIN_BLOCK_ENABLED", False
)
@dataclass @dataclass
+19
View File
@@ -0,0 +1,19 @@
from hy_memory.config import MemoryConfig
def test_chain_block_defaults_disabled(monkeypatch):
monkeypatch.delenv("MEMORY_CODING_CHAIN_BLOCK_ENABLED", raising=False)
assert MemoryConfig().coding.chain_block_enabled is False
def test_chain_block_env_can_enable(monkeypatch):
monkeypatch.setenv("MEMORY_CODING_CHAIN_BLOCK_ENABLED", "true")
assert MemoryConfig().coding.chain_block_enabled is True
def test_chain_block_from_dict_can_enable(monkeypatch):
monkeypatch.delenv("MEMORY_CODING_CHAIN_BLOCK_ENABLED", raising=False)
config = MemoryConfig.from_dict({
"coding": {"chain_block_enabled": True},
})
assert config.coding.chain_block_enabled is True
+28 -1
View File
@@ -62,7 +62,21 @@ def _seed_chain(tmpdir, branch="feat/q4", path=_PATH):
return H return H
def _bind_chain(ctx, store): def _enable_chain_block(ctx):
"""search 侧 chain_block 默认关;测注入路径时显式 opt-in。"""
from hy_memory.config import MemoryConfig
cfg = getattr(ctx, "_config", None)
if cfg is None:
cfg = MemoryConfig()
ctx._config = cfg
cfg.coding.chain_block_enabled = True
ctx._chain_block_enabled = HyMemoryClient._chain_block_enabled.__get__(
ctx, type(ctx))
return ctx
def _bind_chain(ctx, store, *, enable_block=True):
"""把链层挂到 search_ctx 这个 SimpleNamespace 上。 """把链层挂到 search_ctx 这个 SimpleNamespace 上。
search_ctx 是裸 namespace,不是真 client,所以 _decorate_with_chain 这个 search_ctx 是裸 namespace,不是真 client,所以 _decorate_with_chain 这个
@@ -73,6 +87,8 @@ def _bind_chain(ctx, store):
ctx._get_chain_store = lambda owner="": store ctx._get_chain_store = lambda owner="": store
ctx._decorate_with_chain = HyMemoryClient._decorate_with_chain.__get__( ctx._decorate_with_chain = HyMemoryClient._decorate_with_chain.__get__(
ctx, type(ctx)) ctx, type(ctx))
if enable_block:
_enable_chain_block(ctx)
return ctx return ctx
@@ -129,6 +145,17 @@ class TestSearchCodingVariantEndToEnd:
assert "第1版" in blk and "第2版" in blk assert "第1版" in blk and "第2版" in blk
assert "255.0" in blk and "360.0" in blk assert "255.0" in blk and "360.0" in blk
async def test_coding_variant_chain_block_default_off(self, search_ctx, chain_dir):
"""默认不注入 chain_block(省 token);库里有链也返回空块。"""
await self._seed_fact(search_ctx)
# 挂上真链层,但不开启 chain_block_enabled
_bind_chain(search_ctx, _seed_chain(chain_dir), enable_block=False)
resp = await self._search(search_ctx, _STATEMENT, variant="coding")
assert len(resp["facts"]) == 1
assert resp["chain_block"] == ""
assert resp["chain_used"] == []
async def test_facts_not_deduped(self, search_ctx, chain_dir): async def test_facts_not_deduped(self, search_ctx, chain_dir):
"""★ 挂上链的 fact 仍留在 facts 里 —— 刻意不去重。 """★ 挂上链的 fact 仍留在 facts 里 —— 刻意不去重。
+24 -3
View File
@@ -326,12 +326,34 @@ class TestChainDecorate:
os.environ.pop("HCM_BLOCK_OFF", None) os.environ.pop("HCM_BLOCK_OFF", None)
def _ns_with_chain_block(**kwargs):
"""胶水测需要显式 opt-in:默认 chain_block_enabled=False 会在进 store 前短路。"""
import types
from hy_memory.config import MemoryConfig
cfg = MemoryConfig()
cfg.coding.chain_block_enabled = True
ns = types.SimpleNamespace(_config=cfg, **kwargs)
ns._chain_block_enabled = HyMemoryClient._chain_block_enabled.__get__(
ns, type(ns))
return ns
class TestDecorateGlue: class TestDecorateGlue:
"""client._decorate_with_chain —— 胶水层的 fail-safe。""" """client._decorate_with_chain —— 胶水层的 fail-safe。"""
def test_returns_empty_when_chain_unavailable(self): def test_returns_empty_when_disabled_by_default(self):
import types import types
ns = types.SimpleNamespace( ns = types.SimpleNamespace(
_chain_store=None, _chain_store_failed=False,
_get_chain_store=lambda owner="": object(),
)
ns._chain_block_enabled = HyMemoryClient._chain_block_enabled.__get__(
ns, type(ns))
assert HyMemoryClient._decorate_with_chain(ns, [dict(_FACT)]) == ("", [])
def test_returns_empty_when_chain_unavailable(self):
ns = _ns_with_chain_block(
_chain_store=None, _chain_store_failed=True, _chain_store=None, _chain_store_failed=True,
_get_chain_store=lambda owner="": None, _get_chain_store=lambda owner="": None,
) )
@@ -339,13 +361,12 @@ class TestDecorateGlue:
def test_store_exception_degrades_not_raises(self): def test_store_exception_degrades_not_raises(self):
# 这层是增量, 挂了要退回纯向量双层而不是把整次 search 拖失败。 # 这层是增量, 挂了要退回纯向量双层而不是把整次 search 拖失败。
import types
class _Boom: class _Boom:
def decorate(self, *a, **k): def decorate(self, *a, **k):
raise RuntimeError("boom") raise RuntimeError("boom")
ns = types.SimpleNamespace(_get_chain_store=lambda owner="": _Boom()) ns = _ns_with_chain_block(_get_chain_store=lambda owner="": _Boom())
assert HyMemoryClient._decorate_with_chain(ns, [dict(_FACT)]) == ("", []) assert HyMemoryClient._decorate_with_chain(ns, [dict(_FACT)]) == ("", [])