agent-Specialization/server/deep_compression.py

381 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.

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"
"请使用中文,结构清晰,尽量具体,不要省略关键上下文。"
)
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 _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
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}]
for _ in range(max(1, retries)):
try:
response_text = ""
async for chunk in web_terminal.api_client.chat(messages, tools=None, 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 / "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))
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}
old_title = conv_data.get("title") or "未命名"
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="creating_new_conversation",
job_id=job_id,
)
new_conv_id = cm.conversation_manager.create_conversation(
project_path=str(workspace.project_path),
thinking_mode=bool(metadata.get("thinking_mode", False)),
run_mode=metadata.get("run_mode") or ("thinking" if metadata.get("thinking_mode") else "fast"),
initial_messages=[],
model_key=metadata.get("model_key"),
has_images=False,
has_videos=False,
metadata_overrides={
"compression_count": target_count,
"title_locked": True,
"skip_auto_title_generation": True,
}
)
current_record = {
"count": target_count,
"compact_file": relative_compact_path,
"created_at": datetime.now().isoformat(),
"source_conversation_id": conversation_id,
"compressed_conversation_id": new_conv_id,
}
all_records = previous_records + [current_record]
cm.conversation_manager.update_conversation_metadata(
new_conv_id,
{
"compression_count": target_count,
"deep_compression_records": all_records,
"last_deep_compression_record": current_record,
},
)
cm.conversation_manager.update_conversation_title(new_conv_id, f"{old_title}对话 压缩后")
web_terminal.load_conversation(new_conv_id)
guide_message = _build_guide_message(
compression_index=target_count,
compact_file=relative_compact_path,
previous_records=previous_records,
)
# 清理旧对话压缩状态
cm.conversation_manager.update_conversation_metadata(
conversation_id,
{
"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": True,
},
)
_emit(sender, "conversation_resolved", {
"conversation_id": new_conv_id,
"title": f"{old_title}对话 压缩后",
"created": True,
})
_emit(sender, "compression_finished", {
"source_conversation_id": conversation_id,
"conversation_id": new_conv_id,
"in_progress": False,
"compact_file": relative_compact_path,
"job_id": job_id,
})
return {
"success": True,
"compressed_conversation_id": new_conv_id,
"compact_file": relative_compact_path,
"summary_failed": bool(summary_fail_reason),
"guide_message": guide_message,
}