agent-Specialization/server/deep_compression.py

515 lines
20 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.

from __future__ import annotations
import asyncio
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
SUMMARY_PROMPT = (
"由于当前对话过长,系统正在自动压缩。请你基于已有上下文输出一份可继续执行的工作总结,要求:\n"
"1) 任务目标与用户真实诉求\n"
"2) 已完成工作(按时间顺序)\n"
"3) 关键决策与原因\n"
"4) 修改过的文件与核心变更点\n"
"5) 工具调用中的重要结果/错误与修复\n"
"6) 风险与注意事项\n"
"7) 当前正在执行的任务与进度(正在做什么、做到什么程度、卡在哪里)\n"
"8) 下一步具体行动(可直接执行的第一步,尽量具体)\n"
"请使用中文,结构清晰,尽量具体,不要省略关键上下文。\n"
"不要考虑过往对话,只考虑当前对话任务。\n"
"禁止调用任何工具,必须直接输出总结内容。"
)
GUIDE_USER_MESSAGE_TEMPLATE = "当前对话已经被自动压缩(第{count}次)。请阅读{path}并继续工作"
def _emit(sender, event_type: str, payload: Dict[str, Any]):
if not callable(sender):
return
try:
sender(event_type, payload)
except Exception:
pass
def _normalize_deep_compression_records(metadata: Dict[str, Any]) -> List[Dict[str, Any]]:
records = metadata.get("deep_compression_records")
if not isinstance(records, list):
return []
normalized: List[Dict[str, Any]] = []
for item in records:
if not isinstance(item, dict):
continue
try:
count = int(item.get("count", 0) or 0)
except Exception:
count = 0
path = str(item.get("compact_file") or "").strip()
if count <= 0 or not path:
continue
normalized.append({
"count": count,
"compact_file": path,
"created_at": item.get("created_at"),
"source_conversation_id": item.get("source_conversation_id"),
"compressed_conversation_id": item.get("compressed_conversation_id"),
})
normalized.sort(key=lambda x: (int(x.get("count") or 0), str(x.get("created_at") or "")))
deduped: List[Dict[str, Any]] = []
seen = set()
for rec in normalized:
key = (int(rec.get("count") or 0), str(rec.get("compact_file") or ""))
if key in seen:
continue
seen.add(key)
deduped.append(rec)
return deduped
def _build_guide_message(*, compression_index: int, compact_file: str, previous_records: List[Dict[str, Any]]) -> str:
"""生成文件模式的引导语:仅提示压缩文件位置,由模型自行阅读。"""
base = GUIDE_USER_MESSAGE_TEMPLATE.format(count=compression_index, path=compact_file)
if not previous_records:
return base
lines = [base, "", "此前压缩摘要文件位置:"]
for rec in previous_records:
count = int(rec.get("count") or 0)
path = str(rec.get("compact_file") or "").strip()
if count <= 0 or not path:
continue
lines.append(f"- 第{count}次:{path}")
return "\n".join(lines).strip()
def _read_compact_file_content(project_path: Path, relative_path: str) -> str:
"""读取 compact 文件全文,读取失败时返回空串。"""
rel = str(relative_path or "").strip()
if not rel:
return ""
try:
target = (Path(project_path) / rel).resolve()
return target.read_text(encoding="utf-8").strip()
except Exception:
return ""
def _build_inject_guide_message(
*,
project_path: Path,
compression_index: int,
current_record: Dict[str, Any],
previous_records: List[Dict[str, Any]],
) -> str:
"""生成直接注入模式的引导语:把历次压缩文件全文按顺序拼入正文,不提及文件位置。"""
lines: List[str] = [f"当前对话已被第{compression_index}次压缩,以下为历次压缩的完整工作总结,请据此继续工作。"]
all_records = list(previous_records) + [current_record]
for rec in all_records:
count = int(rec.get("count") or 0)
rel_path = str(rec.get("compact_file") or "").strip()
if count <= 0 or not rel_path:
continue
content = _read_compact_file_content(project_path, rel_path)
lines.append("")
lines.append(f"{count}次压缩:")
lines.append(content or "(压缩文件内容读取失败)")
return "\n".join(lines).strip()
def _extract_text_only(content: Any) -> str:
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: List[str] = []
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text":
txt = item.get("text")
if isinstance(txt, str) and txt.strip():
parts.append(txt)
return "\n".join(parts).strip()
return str(content)
def _extract_tool_arg_map(messages: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
tool_map: Dict[str, Dict[str, Any]] = {}
for msg in messages:
if msg.get("role") != "assistant":
continue
for tc in msg.get("tool_calls") or []:
tc_id = tc.get("id") or tc.get("tool_call_id")
if not tc_id:
continue
func = tc.get("function") or {}
name = func.get("name") or tc.get("name") or "unknown_tool"
args_raw = func.get("arguments")
args_obj: Any = args_raw
if isinstance(args_raw, str):
try:
args_obj = json.loads(args_raw)
except Exception:
args_obj = args_raw
tool_map[tc_id] = {"name": name, "arguments": args_obj}
return tool_map
def _collect_last_tool_entries(messages: List[Dict[str, Any]], limit: int = 5, max_content_chars: int = 3000) -> List[Dict[str, Any]]:
tool_arg_map = _extract_tool_arg_map(messages)
entries: List[Dict[str, Any]] = []
for msg in messages:
if msg.get("role") != "tool":
continue
tc_id = msg.get("tool_call_id") or msg.get("id")
mapping = tool_arg_map.get(tc_id, {})
content_text = _extract_text_only(msg.get("content"))
if len(content_text) > max_content_chars:
content_text = content_text[:max_content_chars] + "\n...(已截断)"
entries.append({
"tool_call_id": tc_id,
"tool_name": msg.get("name") or mapping.get("name") or "unknown_tool",
"arguments": mapping.get("arguments"),
"content": content_text,
})
return entries[-max(1, limit):]
def _collect_user_texts(messages: List[Dict[str, Any]]) -> List[str]:
result: List[str] = []
for msg in messages:
if msg.get("role") != "user":
continue
metadata = msg.get("metadata") if isinstance(msg.get("metadata"), dict) else {}
if str(metadata.get("message_source") or "").strip().lower() != "user":
continue
text = _extract_text_only(msg.get("content"))
if text.strip():
result.append(text.strip())
return result
async def _generate_summary(web_terminal, prompt: str, retries: int = 5) -> Tuple[str, Optional[str]]:
last_reason: Optional[str] = None
context = web_terminal.build_context()
messages = web_terminal.build_messages(context, prompt)
# build_messages 不会自动附加 user_input压缩总结必须显式注入
if prompt and isinstance(prompt, str):
messages = list(messages) + [{"role": "user", "content": prompt}]
# 关键:总结请求必须与"用户正常发一条消息"完全无差别,才能 100% 命中前缀缓存。
# 因此 tools 必须照常传入(缺失 tools 字段会改变请求前缀、破坏缓存)。
# 模型即便返回 tool_calls 也会被忽略——我们只取 contentprompt 已要求其直接输出总结。
tools = web_terminal.define_tools()
for _ in range(max(1, retries)):
try:
response_text = ""
async for chunk in web_terminal.api_client.chat(messages, tools=tools, stream=False):
if not isinstance(chunk, dict):
continue
choices = chunk.get("choices") or []
if choices:
msg = choices[0].get("message") or {}
content = msg.get("content")
if isinstance(content, str):
response_text = content
if response_text.strip():
return response_text.strip(), None
last_reason = "模型返回空内容"
except Exception as exc:
last_reason = str(exc)
await asyncio.sleep(0.2)
return f"生成总结失败({last_reason or '未知原因'}", last_reason
def _write_compact_file(
project_path: Path,
*,
compression_index: int,
summary_text: str,
user_inputs: List[str],
last_tools: List[Dict[str, Any]],
latest_user_input: str,
) -> str:
compact_dir = project_path / ".agents" / "compact_result"
compact_dir.mkdir(parents=True, exist_ok=True)
filename = f"compact_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{compression_index:03d}.md"
file_path = compact_dir / filename
lines: List[str] = [
f"# 对话已被第{compression_index}次压缩",
"",
"## 工作总结",
summary_text or "生成总结失败",
"",
"## 用户的所有输入(仅文字)",
]
if user_inputs:
for idx, text in enumerate(user_inputs, start=1):
lines.append(f"{idx}. {text}")
else:
lines.append("- (无)")
lines.extend(["", "## 最近5条工具调用参数 + 结果)"])
if last_tools:
for idx, item in enumerate(last_tools, start=1):
lines.extend([
f"### {idx}. {item.get('tool_name')}",
f"- tool_call_id: {item.get('tool_call_id')}",
"- 参数:",
"```json",
json.dumps(item.get("arguments"), ensure_ascii=False, indent=2),
"```",
"- 结果:",
"```text",
str(item.get("content") or ""),
"```",
"",
])
else:
lines.append("- (无)")
lines.extend(["", "## 用户最新的一次输入"])
if (latest_user_input or "").strip():
lines.append(latest_user_input.strip())
else:
lines.append("(无)")
file_path.write_text("\n".join(lines).strip() + "\n", encoding="utf-8")
return str(file_path.relative_to(project_path))
def _mark_history_compacted(history: List[Dict[str, Any]], *, round_index: int, now: str) -> int:
"""把当前对话历史中所有尚未标记的消息打上 deep_compacted 标记in-place
深压缩按"整段前缀"处理:本次压缩时,历史里所有还没被标记的消息整体视为已压缩前缀。
这天然保证 assistant.tool_calls 与其 tool 响应被一并标记/排除,不会出现配对悬空。
原文保留在 metadata 中,持久化与重新加载时照常显示,仅在 build_messages 构建请求时被跳过。
返回本次新标记的消息条数。
"""
if not isinstance(history, list):
return 0
marked = 0
for msg in history:
if not isinstance(msg, dict):
continue
metadata = msg.get("metadata")
if not isinstance(metadata, dict):
metadata = {}
if metadata.get("deep_compacted"):
continue
metadata["deep_compacted"] = True
metadata["deep_compacted_round"] = round_index
metadata["deep_compacted_at"] = now
msg["metadata"] = metadata
marked += 1
return marked
async def run_deep_compression(
*,
web_terminal,
workspace,
conversation_id: str,
mode: str,
sender=None,
) -> Dict[str, Any]:
cm = web_terminal.context_manager
conv_data = cm.conversation_manager.load_conversation(conversation_id)
if not conv_data:
return {"success": False, "error": f"对话不存在: {conversation_id}"}
metadata = conv_data.get("metadata", {}) or {}
if metadata.get("compression_in_progress"):
return {"success": False, "error": "对话正在压缩中", "in_progress": True}
# 读取个性化压缩设置:
# - compress_form: file生成文件引导语提示位置 / inject把历次压缩文件全文注入引导语
# - compress_behavior: continue注入引导语并触发请求 / wait仅注入引导语等待用户
# compress_behavior 仅作用于手动压缩;自动深压缩永远继续工作。
try:
from modules.personalization_manager import load_personalization_config
pconfig = load_personalization_config(workspace.data_dir) or {}
except Exception:
pconfig = {}
compress_form = str(pconfig.get("deep_compress_form") or "file").strip().lower()
if compress_form not in ("file", "inject"):
compress_form = "file"
compress_behavior = str(pconfig.get("deep_compress_behavior") or "continue").strip().lower()
if compress_behavior not in ("continue", "wait"):
compress_behavior = "continue"
if mode != "manual":
compress_behavior = "continue"
# in-place 压缩全程操作"当前对话"的内存历史:总结、标记、状态写入都依赖
# cm.conversation_history / cm.conversation_metadata。若当前对话不是目标对话
# 必须先切换过去,否则总结会基于错误历史、压缩状态也会写错对话。
if getattr(cm, "current_conversation_id", None) != conversation_id:
try:
web_terminal.load_conversation(conversation_id)
except Exception as exc:
return {"success": False, "error": f"加载目标对话失败: {exc}"}
compression_count = int(metadata.get("compression_count", 0) or 0)
previous_records = _normalize_deep_compression_records(metadata)
previous_max_count = max([int(item.get("count") or 0) for item in previous_records], default=0)
target_count = max(compression_count, previous_max_count) + 1
job_id = f"cmp_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
cm.set_compression_state(
in_progress=True,
mode=mode,
stage="generating_summary",
job_id=job_id,
resume_payload={"conversation_id": conversation_id, "mode": mode},
)
_emit(sender, "compression_state", {
"conversation_id": conversation_id,
"in_progress": True,
"mode": mode,
"stage": "generating_summary",
"job_id": job_id,
})
summary_text, summary_fail_reason = await _generate_summary(web_terminal, SUMMARY_PROMPT, retries=5)
if summary_fail_reason:
_emit(sender, "system_message", {"content": f"自动压缩总结失败,将使用失败占位文本:{summary_fail_reason}"})
cm.set_compression_state(
in_progress=True,
mode=mode,
stage="writing_compact",
job_id=job_id,
)
_emit(sender, "compression_state", {
"conversation_id": conversation_id,
"in_progress": True,
"mode": mode,
"stage": "writing_compact",
"job_id": job_id,
})
messages = conv_data.get("messages") or []
user_inputs = _collect_user_texts(messages)
latest_user_input = user_inputs[-1] if user_inputs else ""
last_tools = _collect_last_tool_entries(messages, limit=5, max_content_chars=3000)
relative_compact_path = _write_compact_file(
Path(workspace.project_path),
compression_index=target_count,
summary_text=summary_text,
user_inputs=user_inputs,
last_tools=last_tools,
latest_user_input=latest_user_input,
)
cm.set_compression_state(
in_progress=True,
mode=mode,
stage="marking_history",
job_id=job_id,
)
# === in-place 压缩:不创建/切换新对话,只把当前对话历史前缀打上 deep_compacted 标记 ===
# 当前对话已在函数开头对齐为目标对话,这里直接对内存历史打标。
now_iso = datetime.now().isoformat()
marked_count = _mark_history_compacted(
cm.conversation_history or [],
round_index=target_count,
now=now_iso,
)
# 标记后立即持久化历史(标记写在每条消息的 metadata 中)。
try:
cm.save_current_conversation()
except Exception as exc:
_emit(sender, "system_message", {"content": f"压缩标记保存失败:{exc}"})
# 关键:重置 current_context_tokens避免自动压缩续接后阈值判断仍读到压缩前的大值而陷入死循环。
# 真实上下文长度会在下一次 API 响应后被重新写入。
try:
cm.conversation_manager.update_token_statistics(
conversation_id,
input_tokens=0,
output_tokens=0,
total_tokens=0,
current_context_tokens=0,
)
except Exception as exc:
_emit(sender, "system_message", {"content": f"压缩后重置上下文统计失败:{exc}"})
current_record = {
"count": target_count,
"compact_file": relative_compact_path,
"created_at": now_iso,
"source_conversation_id": conversation_id,
"compressed_conversation_id": conversation_id,
}
all_records = previous_records + [current_record]
# 构建引导语按压缩形式。inject 模式读取历次压缩文件全文,文件仍会生成,只是不提及位置。
if compress_form == "inject":
guide_message = _build_inject_guide_message(
project_path=Path(workspace.project_path),
compression_index=target_count,
current_record=current_record,
previous_records=previous_records,
)
else:
guide_message = _build_guide_message(
compression_index=target_count,
compact_file=relative_compact_path,
previous_records=previous_records,
)
# 更新对话 metadata压缩记录 + 清理压缩状态标记(同一对话,无切换)。
# 同时清除需要重建的 frozen prompt 缓存,使压缩后下一次请求自动重新加载动态内容。
REBUILD_FROZEN_KEYS = (
"frozen_skills_prompt",
"frozen_workspace_prompt",
"frozen_personalization_prompt",
"frozen_memory_prompt",
)
meta_updates = {
"compression_count": target_count,
"deep_compression_records": all_records,
"last_deep_compression_record": current_record,
"compression_in_progress": False,
"compression_mode": None,
"compression_stage": None,
"compression_job_id": None,
"compression_error": summary_fail_reason,
"compression_resume_payload": None,
"is_ultra_long_conversation": False,
}
for frozen_key in REBUILD_FROZEN_KEYS:
meta_updates[frozen_key] = None
cm.conversation_manager.update_conversation_metadata(conversation_id, meta_updates)
# 同步内存中的 metadata清除 frozen 缓存
try:
if getattr(cm, "current_conversation_id", None) == conversation_id and isinstance(cm.conversation_metadata, dict):
for frozen_key in REBUILD_FROZEN_KEYS:
cm.conversation_metadata.pop(frozen_key, None)
cm.conversation_metadata.update(meta_updates)
except Exception:
pass
_emit(sender, "compression_finished", {
"source_conversation_id": conversation_id,
"conversation_id": conversation_id,
"in_progress": False,
"compact_file": relative_compact_path,
"marked_count": marked_count,
"compress_form": compress_form,
"compress_behavior": compress_behavior,
"job_id": job_id,
})
return {
"success": True,
"in_place": True,
"compressed_conversation_id": conversation_id,
"compact_file": relative_compact_path,
"marked_count": marked_count,
"compress_form": compress_form,
"compress_behavior": compress_behavior,
"summary_failed": bool(summary_fail_reason),
"guide_message": guide_message,
}