agent-Specialization/modules/edit_summary.py

363 lines
14 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.

"""本次工作编辑摘要Edit Summary
write_file / edit_file 成功后由工具执行层调用 `update_edit_summary`
以「该文件本轮工作第一次被编辑前的内容baseline」为基线与当前内容
做合并 diff净变化口径把统计与带行号上下文的 diff 行写入当前工作
user 消息的 metadata.edit_summary并实时广播给前端渲染卡片。
设计要点:
- 同一文件多次编辑baseline 只在首次记录时写入,之后每次编辑重算合并
结果并整体覆盖——metadata 与前端显示始终是最后一次编辑后的最终状态。
- 工作中每次编辑都会持久化auto_save force并广播任务异常停止时
卡片保留最后一刻的状态,刷新后照常显示。
- metadata 不会进入模型上下文api_client 发送前有字段白名单清洗)。
"""
from __future__ import annotations
import difflib
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
EDIT_SUMMARY_VERSION = 1
# 单文件 diff 行数上限(超出截断并标记 truncated
# write_file 内容上限约 100KB8000 行足以覆盖正常文件全量 diff。
MAX_DIFF_LINES = 8000
# baseline 内容存储上限(字符数);超过则不持久化 baseline退化为
# 「仅当次编辑 diff」避免超大文件把对话 JSON 撑爆。
MAX_BASELINE_CHARS = 400_000
# 每个变更块两端保留的上下文行数
CONTEXT_LINES = 3
WebCallback = Optional[Callable[[str, Dict[str, Any]], None]]
def _split_lines(text: Optional[str]) -> List[str]:
if not text:
return []
return str(text).splitlines()
def compute_file_diff(
baseline_text: Optional[str],
current_text: Optional[str],
) -> Dict[str, Any]:
"""计算 baseline → current 的合并 diff。
返回 {added, removed, lines, truncated}lines 元素:
{"type": "context", "content", "old_no", "new_no"}
{"type": "add", "content", "new_no"}
{"type": "remove", "content", "old_no"}
{"type": "sep"} —— 变更块hunk之间的分隔
"""
result: Dict[str, Any] = {"added": 0, "removed": 0, "lines": [], "truncated": False}
old_lines = _split_lines(baseline_text)
new_lines = _split_lines(current_text)
if old_lines == new_lines:
return result
try:
opcodes = difflib.SequenceMatcher(None, old_lines, new_lines).get_opcodes()
except Exception:
# 兜底:不给明细,只给整文件行数计数
result["added"] = len(new_lines)
result["removed"] = len(old_lines)
result["truncated"] = True
return result
# 1) 统计增删 + 收集变更段(两端各扩 CONTEXT_LINES 上下文),相邻段合并
ranges: List[List[int]] = [] # [old_lo, old_hi, new_lo, new_hi]
for tag, i1, i2, j1, j2 in opcodes:
if tag == "equal":
continue
if tag in ("replace", "delete"):
result["removed"] += i2 - i1
if tag in ("replace", "insert"):
result["added"] += j2 - j1
r = [
max(0, i1 - CONTEXT_LINES),
min(len(old_lines), i2 + CONTEXT_LINES),
max(0, j1 - CONTEXT_LINES),
min(len(new_lines), j2 + CONTEXT_LINES),
]
if ranges and r[0] <= ranges[-1][1] and r[2] <= ranges[-1][3]:
ranges[-1][1] = max(ranges[-1][1], r[1])
ranges[-1][3] = max(ranges[-1][3], r[3])
else:
ranges.append(r)
# 2) 逐段生成带行号的 diff 行(段内对子序列再跑一次 diff 保证对齐)
lines: List[Dict[str, Any]] = []
truncated = False
for range_index, (o_lo, o_hi, n_lo, n_hi) in enumerate(ranges):
if range_index > 0:
lines.append({"type": "sep"})
sub_old = old_lines[o_lo:o_hi]
sub_new = new_lines[n_lo:n_hi]
try:
sub_opcodes = difflib.SequenceMatcher(None, sub_old, sub_new).get_opcodes()
except Exception:
sub_opcodes = [("replace", 0, len(sub_old), 0, len(sub_new))]
old_no = o_lo + 1
new_no = n_lo + 1
for tag, i1, i2, j1, j2 in sub_opcodes:
if tag == "equal":
for k in range(i1, i2):
lines.append({
"type": "context",
"content": sub_old[k],
"old_no": old_no,
"new_no": new_no,
})
old_no += 1
new_no += 1
else:
if tag in ("replace", "delete"):
for k in range(i1, i2):
lines.append({"type": "remove", "content": sub_old[k], "old_no": old_no})
old_no += 1
if tag in ("replace", "insert"):
for k in range(j1, j2):
lines.append({"type": "add", "content": sub_new[k], "new_no": new_no})
new_no += 1
if len(lines) >= MAX_DIFF_LINES:
truncated = True
break
if truncated:
break
result["lines"] = lines[:MAX_DIFF_LINES]
result["truncated"] = truncated
return result
def _find_current_work_user_message(context_manager: Any) -> Optional[Dict[str, Any]]:
"""定位当前这轮工作归属的 user 消息(与 work_timer / 浅版本控制同锚)。"""
history = getattr(context_manager, "conversation_history", None) or []
# 1) 精确:当前工作 user 消息 id与浅版本控制共用同一归属字段
message_id = getattr(context_manager, "current_shallow_message_id", None)
if message_id:
for msg in reversed(history):
if (
isinstance(msg, dict)
and msg.get("role") == "user"
and msg.get("message_id") == message_id
):
return msg
# 2) 兜底:最后一条仍在 working 的 user 消息(工具执行期间必然 working
for msg in reversed(history):
if not isinstance(msg, dict) or msg.get("role") != "user":
continue
timer = (msg.get("metadata") or {}).get("work_timer")
if isinstance(timer, dict) and timer.get("status") == "working":
return msg
# 3) 再兜底:最后一条 starts_work 的 user 消息
for msg in reversed(history):
if (
isinstance(msg, dict)
and msg.get("role") == "user"
and (msg.get("metadata") or {}).get("starts_work") is True
):
return msg
return None
def _strip_fulltext_for_broadcast(summary: Dict[str, Any]) -> Dict[str, Any]:
"""广播副本剥离 current_text前端只用 lines/计数,且全文可能很大)。
持久化的对话 JSON 保留 current_text供修改留痕modify_history重建 diff。
"""
files = summary.get("files")
if not isinstance(files, list):
return summary
stripped_files: List[Dict[str, Any]] = []
changed = False
for entry in files:
if isinstance(entry, dict) and "current_text" in entry:
entry = {k: v for k, v in entry.items() if k != "current_text"}
changed = True
stripped_files.append(entry)
if not changed:
return summary
out = dict(summary)
out["files"] = stripped_files
return out
def _persist_and_broadcast(
context_manager: Any,
conversation_id: str,
msg: Dict[str, Any],
summary: Dict[str, Any],
web_callback: WebCallback,
) -> None:
"""持久化对话并广播最新编辑摘要;同步刷新修改留痕文件。"""
# 实时修改留痕:每次摘要变化即重渲染本轮 diff 文件(编辑时刻内容,零磁盘依赖)
try:
from modules.modify_history import update_modify_history_for_task
update_modify_history_for_task(context_manager, msg, summary)
except Exception as exc:
print(f"⚠️ 修改留痕实时写入失败: {exc}")
try:
context_manager.auto_save_conversation(force=True)
except Exception as exc:
print(f"⚠️ 编辑摘要持久化失败: {exc}")
if callable(web_callback):
try:
web_callback("edit_summary_updated", {
"conversation_id": conversation_id,
"message_id": msg.get("message_id"),
"edit_summary": _strip_fulltext_for_broadcast(summary),
})
except Exception:
pass
def update_edit_summary(
context_manager: Any,
*,
path: Any,
original_text: Optional[str],
current_text: Optional[str],
web_callback: WebCallback = None,
) -> None:
"""write_file / edit_file 成功后更新当前工作 user 消息的 edit_summary。
original_text本次编辑前的文件全文新建文件为 None
current_text本次编辑后的文件全文。
"""
try:
rel_path = str(path or "").strip().replace("\\", "/")
conversation_id = getattr(context_manager, "current_conversation_id", None)
if not rel_path or not conversation_id:
return
msg = _find_current_work_user_message(context_manager)
if msg is None:
return
metadata = msg.setdefault("metadata", {})
summary = metadata.get("edit_summary")
if not isinstance(summary, dict) or not isinstance(summary.get("files"), list):
summary = {"version": EDIT_SUMMARY_VERSION, "updated_at": "", "files": []}
metadata["edit_summary"] = summary
entry = next(
(
item
for item in summary["files"]
if isinstance(item, dict) and item.get("path") == rel_path
),
None,
)
if entry is None:
# 首次记录:写入 baseline本轮工作第一次编辑前的内容None 表示新建文件)。
# 此后 baseline 不再变化,保证多次编辑合并为净变化。
baseline_too_large = isinstance(original_text, str) and len(original_text) > MAX_BASELINE_CHARS
entry = {
"path": rel_path,
"baseline": None if baseline_too_large else original_text,
"baseline_truncated": baseline_too_large,
"created_at": datetime.now().isoformat(),
}
summary["files"].append(entry)
# baseline 因超大被丢弃时无法用真实基线重算:退化为当次编辑 diff
if entry.get("baseline_truncated") and isinstance(original_text, str):
baseline_for_diff: Optional[str] = original_text
else:
baseline_for_diff = entry.get("baseline")
diff = compute_file_diff(baseline_for_diff, current_text)
# 新建文件(首次记录时文件不存在)全程保持 added其余为 modified。
# delete_file 走 remove_edit_summary_entry 移除记录,这里不会出现 deleted。
status = "added" if entry.get("baseline") is None and not entry.get("baseline_truncated") else "modified"
now_iso = datetime.now().isoformat()
current_too_large = isinstance(current_text, str) and len(current_text) > MAX_BASELINE_CHARS
entry.update({
"status": status,
"added": diff["added"],
"removed": diff["removed"],
"lines": diff["lines"],
"truncated": bool(diff["truncated"]) or bool(entry.get("baseline_truncated")),
# 编辑时刻的最新全文:供修改留痕重建 diff不依赖任务结束时的磁盘状态
"current_text": None if current_too_large else current_text,
"current_truncated": current_too_large,
"updated_at": now_iso,
})
summary["updated_at"] = now_iso
_persist_and_broadcast(context_manager, conversation_id, msg, summary, web_callback)
except Exception as exc:
print(f"⚠️ 更新编辑摘要失败: {exc}")
def _mutate_summary_files(
context_manager: Any,
web_callback: WebCallback,
mutator: Callable[[List[Dict[str, Any]]], bool],
) -> None:
"""读取-修改-写回当前工作 user 消息的 edit_summary.files。"""
try:
conversation_id = getattr(context_manager, "current_conversation_id", None)
if not conversation_id:
return
msg = _find_current_work_user_message(context_manager)
if msg is None:
return
metadata = msg.get("metadata") or {}
summary = metadata.get("edit_summary")
if not isinstance(summary, dict) or not isinstance(summary.get("files"), list):
return
if not mutator(summary["files"]):
return
summary["updated_at"] = datetime.now().isoformat()
msg["metadata"] = metadata
_persist_and_broadcast(context_manager, conversation_id, msg, summary, web_callback)
except Exception as exc:
print(f"⚠️ 更新编辑摘要失败: {exc}")
def remove_edit_summary_entry(
context_manager: Any,
*,
path: Any,
web_callback: WebCallback = None,
) -> None:
"""文件被删除后从编辑摘要中移除(与快捷窗口文件记录行为一致)。"""
rel_path = str(path or "").strip().replace("\\", "/")
if not rel_path:
return
def _remove(files: List[Dict[str, Any]]) -> bool:
before = len(files)
files[:] = [item for item in files if item.get("path") != rel_path]
return len(files) != before
_mutate_summary_files(context_manager, web_callback, _remove)
def rename_edit_summary_entry(
context_manager: Any,
*,
old_path: Any,
new_path: Any,
web_callback: WebCallback = None,
) -> None:
"""文件重命名后同步更新编辑摘要中的路径(与快捷窗口文件记录行为一致)。"""
old_rel = str(old_path or "").strip().replace("\\", "/")
new_rel = str(new_path or "").strip().replace("\\", "/")
if not old_rel or not new_rel or old_rel == new_rel:
return
def _rename(files: List[Dict[str, Any]]) -> bool:
for item in files:
if item.get("path") == old_rel:
item["path"] = new_rel
item["updated_at"] = datetime.now().isoformat()
return True
return False
_mutate_summary_files(context_manager, web_callback, _rename)