Files
Hy-Memory/hy_memory/coding/reconciler.py
T
alvinfeiandClaude Opus 5.5 4746681efe fix(sdk): coding reconcile model failures fail the write, nothing half-applied
Experience reconcile no longer falls back to ADD-all, and fact reconcile no
longer inserts without superseding, when the model call fails, returns empty
content, or returns a reply that is not a JSON object with an ops list. The
write returns success=false, agent_error_code=RECONCILE_FAILED and the
cause in reconcile_error_code (LLM_UNAVAILABLE / EMPTY_RESPONSE /
JSON_PARSE_FAILED), the same as chat.

Each reconciler is split into plan() (retrieve + model decision, no store)
and apply() (store). CodingWriter plans both layers before applying either,
so a decision failure in either layer stores no experience or fact and
supersedes no older fact. reconcile() still runs plan + apply.

An experience reply of {"ops": []} is a valid "no changes" decision and
stores nothing. A non-empty ops list with no recognizable op is a parse
failure. A fact reply of {"ops": []} inserts without superseding.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 17:01:47 +08:00

668 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""
Coding Memory Reconciler
输入:List[CodingMemoryDraft](来自 extractor)
输出:List[ReconcileOp] —— ADD / UPDATE / DELETE / SKIP,**ops 数可超过 drafts 数**
(DELETE 由 LLM 在 draft 与 candidate 关系中观察到"明确否定/作废"时附带产出)
执行端:reconcile() 调用 store 完成实际 ADD / UPDATE / DELETE 持久化操作。
CodingWriter 分两步用:先 plan()(检索 + LLM 决策,不落库),等经验层与事实层的
模型决策都成功后再 apply()(落库)。LLM 失败 / 空回复 / 解析不出时 plan() 抛
CodingWriteError(RECONCILE_FAILED),本次写入什么都不存。
复用 LLMConfig.model(与 chat reconciler 共用)。
对外 scene 与调用方式见 docs/scene_coding_usage.md。
"""
import json
import logging
import time
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Tuple
from ..agent.llm_provider import LLMProvider
from ..core.embed_service import EmbedService
from ..prompts import (
get_prompt,
normalize_language,
resolve_language,
with_output_language,
)
from .judge import _store_coding_log
from .store import CodingMemoryStore
from .types import CodingMemory, CodingMemoryDraft, CodingWriteError, ReconcileOp
logger = logging.getLogger(__name__)
def _resolve_configured_temperature(llm_provider) -> Optional[float]:
"""从 MemoryConfig.llm 解析温度;None = 请求里不带该参数。"""
config = getattr(llm_provider, "config", None)
llm = getattr(config, "llm", None) if config is not None else None
if llm is not None:
if getattr(llm, "omit_temperature", False):
return None
temp = getattr(llm, "temperature", None)
if temp is not None:
return float(temp)
inner = getattr(llm_provider, "_llm_config", None)
temp = getattr(inner, "temperature", None) if inner is not None else None
return float(temp) if temp is not None else None
# ================================================================
# Prompt
# ================================================================
from ..prompts.en import CODING_RECONCILE_PROMPT as RECONCILE_PROMPT # noqa: E402,F401 (re-export)
def _drafts_language_text(drafts: List[CodingMemoryDraft]) -> str:
"""Build language-routing text from new drafts, not retrieved candidates."""
parts = []
for draft in drafts:
parts.extend([
draft.task,
*(draft.search_keys or []),
draft.solution,
draft.boundary_envs,
])
return "\n".join(str(part).strip() for part in parts if str(part).strip())
def build_experience_reconcile_prompt(
drafts: List[CodingMemoryDraft],
candidates_per_draft: List[List[CodingMemory]],
language: Optional[str] = None,
) -> Tuple[str, str]:
"""Return the experience reconcile prompt and its language code.
Explicit ``language`` wins; otherwise zh/en auto-detection over the drafts.
"""
drafts_block = CodingMemoryReconciler._format_drafts(drafts)
candidates_block = CodingMemoryReconciler._format_candidates(
drafts, candidates_per_draft,
)
explicit = normalize_language(language)
code = resolve_language(explicit, _drafts_language_text(drafts))
prompt = get_prompt("CODING_RECONCILE_PROMPT", code).format(
drafts_block=drafts_block,
candidates_block=candidates_block,
)
return with_output_language(prompt, code, explicit=explicit is not None), code
@dataclass
class ExperienceReconcilePlan:
"""plan() 的产物:模型已决策、尚未落库。apply() 才执行 store 操作。"""
drafts: List[CodingMemoryDraft]
candidates_per_draft: List[List[CodingMemory]] = field(default_factory=list)
ops: List[ReconcileOp] = field(default_factory=list)
# ================================================================
# CodingMemoryReconciler
# ================================================================
class CodingMemoryReconciler:
"""
Reconcile + 持久化执行器。
一次调用:
1. 为每个 draft 用 task embedding 检索 top-3 candidates
2. 单次 LLM 决策产出 ReconcileOp[]
3. 调 store 执行 ADD / UPDATE / DELETE
"""
def __init__(
self,
store: CodingMemoryStore,
embed_service: EmbedService,
llm_provider: LLMProvider,
*,
max_tokens: int = 1500,
temperature: Optional[float] = None,
candidates_per_draft: int = 3,
cache: Any = None,
):
self.store = store
self.embed_service = embed_service
self.llm_provider = llm_provider
self.max_tokens = max_tokens
# cache 非空时落 CODING_RECONCILE pipeline log(best-effort)
self.cache = cache
# None → 复用 LLMConfig.temperature(避免硬编码触发 kimi/deepseek 的温度硬约束)
if temperature is None:
# None 一路传到 provider = 不带该参数;此前兜 0.1 会绕过用户配置,
# 在只接受特定 temperature 的网关上(Opus 5 只收 1 或不传)直接 400
temperature = _resolve_configured_temperature(llm_provider)
self.temperature = temperature
self.candidates_per_draft = candidates_per_draft
async def reconcile(
self,
drafts: List[CodingMemoryDraft],
*,
request_id: str = "",
user_id: str = "",
agent_id: str = "default_agent",
session_id: str = "",
language: Optional[str] = None,
) -> List[ReconcileOp]:
"""
执行 reconcile + 持久化(plan() 后紧接 apply())。返回执行的 ReconcileOp[]
(含成功执行的 ADD/UPDATE/DELETE/SKIP)。
LLM 失败、空回复或解析不出时抛 CodingWriteError(RECONCILE_FAILED),什么都
不落库——不再回退成「全部 ADD」。
"""
plan = await self.plan(
drafts,
request_id=request_id,
user_id=user_id,
agent_id=agent_id,
session_id=session_id,
language=language,
)
return await self.apply(plan)
async def plan(
self,
drafts: List[CodingMemoryDraft],
*,
request_id: str = "",
user_id: str = "",
agent_id: str = "default_agent",
session_id: str = "",
language: Optional[str] = None,
) -> ExperienceReconcilePlan:
"""检索 candidates + LLM 决策,不落库。
有效回复 = 一个 JSON 对象,其 "ops" 是列表:
- {"ops": []}:模型判定「无需任何操作」(与 chat reconcile 的 [] 同义),
成功、本层什么都不存;
- 非空列表:至少要有一条可识别的 op(ADD/UPDATE/DELETE/SKIP),否则视为
解析失败。漏掉的 draft 仍按原规则补 ADD(那是对有效回复的校验兜底,
不是模型失败兜底)。
LLM 调用失败(LLM_UNAVAILABLE)、200 但 content 为空(EMPTY_RESPONSE)、
回复不是上述形状(JSON_PARSE_FAILED)时抛 CodingWriteError(RECONCILE_FAILED)。
cache 非空时落 CODING_RECONCILE pipeline log(成功、失败都落)。
"""
if not drafts:
return ExperienceReconcilePlan(drafts=[])
# 从 draft 回填 trace 身份(writer 通常显式传,这里兜底)
user_id = user_id or drafts[0].user_id
agent_id = agent_id or drafts[0].agent_id
session_id = session_id or (drafts[0].session_id or "")
# 1. 检索 candidates
candidates_per_draft: List[List[CodingMemory]] = await self._retrieve_candidates(drafts)
# 2. LLM 决策
_t0 = time.perf_counter()
_decide_prompt = ""
_decide_raw = ""
_decide_resp = None
failure: Optional[Tuple[str, str]] = None
ops: List[ReconcileOp] = []
try:
ops, _decide_prompt, _decide_raw, _decide_resp = await self._llm_decide(
drafts, candidates_per_draft, language=language,
)
failure = self._reply_failure(_decide_raw)
except Exception as e:
logger.warning(f"[coding-reconcile] LLM decide failed: {e}")
_decide_raw = f"[LLM error] {type(e).__name__}: {str(e)[:300]}"
failure = (
"LLM_UNAVAILABLE",
f"coding reconcile LLM call failed: {type(e).__name__}: {e}",
)
_decide_elapsed = round((time.perf_counter() - _t0) * 1000, 2)
# CODING_RECONCILE:LLM 决策完成即打点(执行入库之前,crash 不丢)
await _store_coding_log(
self.cache, request_id=request_id, user_id=user_id, agent_id=agent_id,
session_id=session_id, step="CODING_RECONCILE",
prompt=_decide_prompt, response=_decide_raw,
parsed=json.dumps(
{"error": failure[0], "message": failure[1], "stored": 0}
if failure else {"ops": [o.to_dict() for o in ops]},
ensure_ascii=False, default=str,
),
elapsed_ms=_decide_elapsed,
prompt_tokens=getattr(_decide_resp, "prompt_tokens", 0) if _decide_resp else 0,
completion_tokens=getattr(_decide_resp, "completion_tokens", 0) if _decide_resp else 0,
total_tokens=getattr(_decide_resp, "tokens_used", 0) if _decide_resp else 0,
)
if failure is not None:
code, message = failure
logger.warning(f"[coding-reconcile] {code}: {message}")
raise CodingWriteError("RECONCILE_FAILED", message, cause=code)
return ExperienceReconcilePlan(
drafts=list(drafts),
candidates_per_draft=candidates_per_draft,
ops=ops,
)
async def apply(self, plan: ExperienceReconcilePlan) -> List[ReconcileOp]:
"""执行 plan() 的决策(store ADD / UPDATE / DELETE)。"""
if not plan.drafts:
return []
if not plan.ops:
# 有效的 {"ops": []}:模型判定无需任何操作,本层什么都不存。
logger.info(
f"[coding-reconcile] drafts={len(plan.drafts)} LLM decided no ops"
)
return []
executed = await self._execute(
plan.ops, plan.drafts, plan.candidates_per_draft,
)
logger.info(
f"[coding-reconcile] drafts={len(plan.drafts)} ops={len(executed)} "
+ ", ".join(f"{a}={sum(1 for o in executed if o.action == a)}"
for a in ("ADD", "UPDATE", "DELETE", "SKIP"))
)
return executed
# ------------------------------------------------------------
# 检索 candidates
# ------------------------------------------------------------
async def _retrieve_candidates(
self, drafts: List[CodingMemoryDraft]
) -> List[List[CodingMemory]]:
"""每个 draft 用 task + search_keys 多路 embedding 检索同 user 的 top-N candidates。
只用 task 检索时,若新 draft 的 task 措辞与旧记忆差异较大(同一件事换个
说法),会漏召回本应合并的候选。这里把 search_keys 一并作为检索入口,
多路召回后按 experience_id 去重(保留最高分),扩大候选覆盖面,降低漏判 ADD。
"""
result: List[List[CodingMemory]] = []
for d in drafts:
# A coding mutation must be attributable to one conversation.
# Empty/legacy session metadata stays readable through ordinary
# search, but it cannot authorize UPDATE/DELETE.
if not d.session_id:
result.append([])
continue
try:
# task 为主入口,search_keys 为附加入口(去重、去空)
queries = [d.task] + [k for k in (d.search_keys or []) if k and k != d.task]
embs = await self.embed_service.embed_batch(queries)
# 多路检索 → 按 experience_id 聚合,保留最高分
best: Dict[str, float] = {}
for emb in embs:
hits = await self.store.search_by_query_embedding(
emb,
user_id=d.user_id,
agent_id=d.agent_id,
session_id=d.session_id,
workspace_id=d.workspace_id,
branch=d.branch,
top=self.candidates_per_draft * 4,
include_sources=[d.source],
)
for h in hits:
eid = h["experience_id"]
sc = h.get("score", 0.0)
if eid not in best or sc > best[eid]:
best[eid] = sc
# 按分数排序取 top-N
cand_ids = [eid for eid, _ in sorted(best.items(), key=lambda x: -x[1])][
: self.candidates_per_draft
]
cands = await self.store.get_many(cand_ids, user_id=d.user_id)
result.append(cands)
except Exception as e:
logger.warning(f"[coding-reconcile] retrieve candidates failed: {e}")
result.append([])
return result
# ------------------------------------------------------------
# LLM 决策
# ------------------------------------------------------------
async def _llm_decide(
self,
drafts: List[CodingMemoryDraft],
candidates_per_draft: List[List[CodingMemory]],
language: Optional[str] = None,
) -> Tuple[List[ReconcileOp], str, str, Any]:
"""返回 (ops, prompt, raw_response, llm_resp),供上层打 CODING_RECONCILE log。"""
prompt, _ = build_experience_reconcile_prompt(
drafts, candidates_per_draft, language=language,
)
resp = await self.llm_provider.complete(
prompt=prompt,
max_tokens=self.max_tokens,
audit_stage="coding_reconcile",
)
return self._parse_ops(resp.content), prompt, resp.content, resp
@staticmethod
def _format_drafts(drafts: List[CodingMemoryDraft]) -> str:
rows = []
for i, d in enumerate(drafts):
rows.append(
f"draft[{i}]:\n"
f" task: {d.task!r}\n"
f" search_keys: {d.search_keys}\n"
f" solution: {_truncate(d.solution, 800)!r}\n"
f" boundary_envs: {_truncate(d.boundary_envs, 400)!r}"
)
return "\n\n".join(rows) if rows else "(none)"
@staticmethod
def _format_candidates(
drafts: List[CodingMemoryDraft],
candidates_per_draft: List[List[CodingMemory]],
) -> str:
rows = []
for i, d in enumerate(drafts):
cands = candidates_per_draft[i] if i < len(candidates_per_draft) else []
if not cands:
rows.append(f"for draft[{i}]: (no candidates)")
continue
cand_rows = []
for c in cands:
cand_rows.append(
f" - experience_id={c.experience_id}\n"
f" task: {c.task!r}\n"
f" solution: {_truncate(c.solution, 600)!r}\n"
f" boundary_envs: {_truncate(c.boundary_envs, 300)!r}\n"
f" updated_at: {c.updated_at.isoformat() if c.updated_at else 'n/a'}"
)
rows.append(f"for draft[{i}]:\n" + "\n".join(cand_rows))
return "\n\n".join(rows) if rows else "(none)"
@classmethod
def _reply_failure(cls, text: Optional[str]) -> Optional[Tuple[str, str]]:
"""回复本身是失败时返回 (cause, message);有效回复(含 {"ops": []})返回 None。"""
if not (text or "").strip():
return "EMPTY_RESPONSE", "coding reconcile LLM returned empty response"
obj = cls._load_object(text)
ops_raw = obj.get("ops") if isinstance(obj, dict) else None
if not isinstance(ops_raw, list):
return (
"JSON_PARSE_FAILED",
f"coding reconcile output has no ops list ({len(text)} chars)",
)
if ops_raw and not cls._parse_ops(text):
return (
"JSON_PARSE_FAILED",
f"coding reconcile output has no recognizable op ({len(text)} chars)",
)
return None
@staticmethod
def _load_object(text: Optional[str]) -> Optional[Dict[str, Any]]:
"""宽松解析出顶层 JSON 对象;解析不出返回 None。"""
if not text:
return None
s = text.strip()
# 去 markdown fence
if s.startswith("```"):
lines = s.split("\n")
body, in_fence = [], False
for line in lines:
if line.startswith("```"):
in_fence = not in_fence
continue
body.append(line)
s = "\n".join(body).strip() or s
obj: Optional[Dict[str, Any]] = None
try:
obj = json.loads(s)
except Exception:
start = s.find("{")
end = s.rfind("}")
if start != -1 and end > start:
try:
obj = json.loads(s[start: end + 1])
except Exception:
obj = None
return obj if isinstance(obj, dict) else None
@classmethod
def _parse_ops(cls, text: str) -> List[ReconcileOp]:
"""宽松解析 ops 列表。"""
if not text:
return []
obj = cls._load_object(text)
if not obj:
logger.warning(
f"[coding-reconcile] failed to parse ops output ({len(text or '')} chars)"
)
logger.debug(f"[coding-reconcile] unparsable ops output: {text!r}")
return []
ops_raw = obj.get("ops") or []
if not isinstance(ops_raw, list):
return []
ops: List[ReconcileOp] = []
for raw in ops_raw:
if not isinstance(raw, dict):
continue
action = (raw.get("action") or "").strip().upper()
if action not in ("ADD", "UPDATE", "DELETE", "SKIP"):
continue
di = raw.get("draft_idx")
if di is not None:
try:
di = int(di)
except Exception:
di = None
ops.append(ReconcileOp(
action=action, # type: ignore[arg-type]
draft_idx=di,
target_experience_id=raw.get("target_experience_id"),
reason=raw.get("reason"),
))
return ops
# ------------------------------------------------------------
# 执行
# ------------------------------------------------------------
async def _execute(
self,
ops: List[ReconcileOp],
drafts: List[CodingMemoryDraft],
candidates_per_draft: List[List[CodingMemory]],
) -> List[ReconcileOp]:
"""
校验 + 执行 ops。返回成功执行的 ops(filter 掉无效 op)。
校验规则:
- ADD 需 draft_idx 合法
- UPDATE 需 draft_idx 合法 + target_experience_id 非空
- DELETE 需 target_experience_id 非空
- SKIP 不执行任何 store 操作
向量化失败(CodingWriteError(EMBEDDING_FAILED))不吞成「少执行一条」,
原样抛给 CodingWriter,整次写入报失败。
"""
executed: List[ReconcileOp] = []
# 已被 UPDATE 占用的 draft_idx → 防止同 draft 被重复 ADD/UPDATE
consumed: set = set()
for op in ops:
if op.action == "ADD":
if op.draft_idx is None or not (0 <= op.draft_idx < len(drafts)):
logger.warning(f"[coding-reconcile] drop invalid ADD: {_op_ref(op)}")
continue
if op.draft_idx in consumed:
logger.warning(f"[coding-reconcile] drop ADD on consumed draft: {_op_ref(op)}")
continue
d = drafts[op.draft_idx]
memory = self._draft_to_new_memory(d)
try:
await self.store.insert(memory)
op.target_experience_id = memory.experience_id # 回写 id 供 caller 读
executed.append(op)
consumed.add(op.draft_idx)
except CodingWriteError:
raise
except Exception as e:
logger.warning(f"[coding-reconcile] ADD failed: {e}")
elif op.action == "UPDATE":
if op.draft_idx is None or not (0 <= op.draft_idx < len(drafts)):
logger.warning(f"[coding-reconcile] drop invalid UPDATE: {_op_ref(op)}")
continue
if not op.target_experience_id:
logger.warning(f"[coding-reconcile] drop UPDATE missing target: {_op_ref(op)}")
continue
if not self._target_was_offered(op, candidates_per_draft):
logger.warning(
f"[coding-reconcile] drop UPDATE outside authority scope: {_op_ref(op)}"
)
continue
if op.draft_idx in consumed:
logger.warning(f"[coding-reconcile] drop UPDATE on consumed draft: {_op_ref(op)}")
continue
d = drafts[op.draft_idx]
memory = self._draft_to_new_memory(d, experience_id=op.target_experience_id)
# UPDATE 覆盖正文不留痕,但事实链是元数据,得保下来(§4:UPDATE 取并集)
prev = await self.store.get_by_id(
op.target_experience_id, user_id=d.user_id
)
if prev is not None:
memory.source_fact_ids = list(prev.source_fact_ids)
try:
await self.store.update(memory)
executed.append(op)
consumed.add(op.draft_idx)
except CodingWriteError:
raise
except Exception as e:
logger.warning(f"[coding-reconcile] UPDATE failed: {e}")
elif op.action == "DELETE":
if not op.target_experience_id:
logger.warning(f"[coding-reconcile] drop DELETE missing target: {_op_ref(op)}")
continue
if not self._target_was_offered(op, candidates_per_draft):
logger.warning(
f"[coding-reconcile] drop DELETE outside authority scope: {_op_ref(op)}"
)
continue
# DELETE 需要知道 user_id;通过 draft_idx(如有)或第一个 draft 推断
user_id = self._infer_user_id(op, drafts)
if not user_id:
logger.warning(f"[coding-reconcile] drop DELETE no user_id context: {_op_ref(op)}")
continue
try:
ok = await self.store.delete(op.target_experience_id, user_id=user_id)
if ok:
executed.append(op)
except Exception as e:
logger.warning(f"[coding-reconcile] DELETE failed: {e}")
elif op.action == "SKIP":
if op.draft_idx is not None and 0 <= op.draft_idx < len(drafts):
consumed.add(op.draft_idx)
executed.append(op)
# 兜底:每个 draft 至少要被处理一次
for i in range(len(drafts)):
if i in consumed:
continue
logger.info(f"[coding-reconcile] draft[{i}] not handled, fallback ADD")
d = drafts[i]
memory = self._draft_to_new_memory(d)
try:
await self.store.insert(memory)
executed.append(ReconcileOp(
action="ADD", draft_idx=i,
target_experience_id=memory.experience_id,
reason="fallback ADD (no LLM op)",
))
except CodingWriteError:
raise
except Exception as e:
logger.warning(f"[coding-reconcile] fallback ADD failed: {e}")
return executed
@staticmethod
def _target_was_offered(
op: ReconcileOp,
candidates_per_draft: List[List[CodingMemory]],
) -> bool:
"""Only mutate a target frozen into this draft's scoped candidate set."""
if op.draft_idx is None or not (0 <= op.draft_idx < len(candidates_per_draft)):
return False
return any(
candidate.experience_id == op.target_experience_id
for candidate in candidates_per_draft[op.draft_idx]
)
@staticmethod
def _infer_user_id(
op: ReconcileOp, drafts: List[CodingMemoryDraft]
) -> Optional[str]:
if op.draft_idx is not None and 0 <= op.draft_idx < len(drafts):
return drafts[op.draft_idx].user_id
# 后备:用 drafts 里第一个非空 user_id(同一 reconcile 调用通常同 user)
for d in drafts:
if d.user_id:
return d.user_id
return None
@staticmethod
def _draft_to_new_memory(
d: CodingMemoryDraft,
*,
experience_id: Optional[str] = None,
) -> CodingMemory:
return CodingMemory(
experience_id=experience_id or str(uuid.uuid4()),
user_id=d.user_id,
agent_id=d.agent_id,
task=d.task,
search_keys=list(d.search_keys),
solution=d.solution,
boundary_envs=d.boundary_envs,
workspace_id=d.workspace_id,
branch=d.branch,
session_id=d.session_id,
files=list(d.files),
confidence=d.confidence,
source=d.source,
extraction_source=d.extraction_source,
memory_at=d.memory_at,
)
# ================================================================
# Helpers
# ================================================================
def _op_ref(op: ReconcileOp) -> str:
"""op 的日志引用:只带动作与编号,不带 LLM 写的 reason。"""
return (
f"action={op.action} draft_idx={op.draft_idx} "
f"target={op.target_experience_id}"
)
def _truncate(s: str, n: int) -> str:
if not s:
return ""
if len(s) <= n:
return s
return s[: n - 3] + "..."