refactor(deep_compression): 改造深度压缩消息结构、提示词加载与前端展示

- 删除 compact 文件和 inject guide 中的最近工具记录
- 新结构:用户所有输入(按压缩轮次分段)+ 历次压缩总结 + 最近一次输入
- 用户输入分段标记改为 <第N次压缩> 和 <当前触发的第X次压缩>
- 压缩总结提示词迁移到 prompts/deep_compression_summary.txt
- deep_compression_records 增加 user_inputs_before 和 summary 字段
- 压缩后清空全部 frozen prompt 缓存
- 修复 wait 模式 in-place 压缩前端不刷新问题
- 更新手动压缩确认弹窗文案
- 修复 _apply_workspace_personalization_preferences 测试 mock 签名
- 清理 context.py 中误导性的主提示词构建参数
This commit is contained in:
JOJO 2026-06-18 14:50:16 +08:00
parent c5dde27faa
commit f75e2f07a3
6 changed files with 178 additions and 140 deletions

View File

@ -1,6 +1,5 @@
import asyncio import asyncio
import json import json
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional, Set from typing import Any, Dict, List, Optional, Set
@ -380,18 +379,8 @@ class MainTerminalContextMixin:
def _build_main_system_prompt() -> str: def _build_main_system_prompt() -> str:
system_prompt_template = self.load_prompt(prompt_name) system_prompt_template = self.load_prompt(prompt_name)
container_path = self.container_mount_path or "/workspace" # main_system.txt / main_system_qwenvl.txt 仅使用 {model_description}
container_cpus = self.container_cpu_limit
container_memory = self.container_memory_limit
project_storage = self.project_storage_limit
return system_prompt_template.format( return system_prompt_template.format(
project_path=container_path,
container_path=container_path,
container_cpus=container_cpus,
container_memory=container_memory,
project_storage=project_storage,
file_tree=context["project_info"]["file_tree"],
current_time=datetime.now().strftime("%Y-%m-%d %H"),
model_description=prompt_replacements.get("model_description", "") model_description=prompt_replacements.get("model_description", "")
) )

View File

@ -0,0 +1,17 @@
由于当前对话过长,系统正在自动压缩。请你基于已有上下文输出一份可继续执行的工作总结,要求:
1) 任务目标与用户真实诉求:用户到底想让你完成什么。
2) 已完成工作:按时间顺序列出关键步骤,包含涉及文件和核心结果。
3) 关键决策与原因:为什么这样选,而不是别的方案。
4) 修改过的文件与核心变更点:具体到文件路径和改动概要。
5) 工具调用中的重要结果/错误与修复:哪些尝试失败过,最终怎么解决的。
6) 风险与注意事项:继续工作时需要规避的问题。
7) 当前正在执行的任务与进度:正在做什么、做到什么程度、卡在哪里。
8) 下一步具体行动:必须足够具体,至少包含以下信息:
- 如果要读取文件,列出具体文件路径。
- 如果要搜索,列出搜索关键词和范围。
- 如果要修改文件,说明文件路径和预期改动。
- 如果要运行命令,列出具体命令。
- 如果要验证,说明验证方式和预期结果。
请使用中文,结构清晰,尽量具体,不要省略关键上下文。
不要考虑过往对话,只考虑当前对话任务。
禁止调用任何工具,必须直接输出总结内容。

View File

@ -1253,6 +1253,29 @@ def compress_conversation(conversation_id, terminal: WebTerminal, workspace: Use
) )
response_payload["auto_task_started"] = False response_payload["auto_task_started"] = False
response_payload["guide_inserted"] = True response_payload["guide_inserted"] = True
# 通知前端实时显示这条 compact 消息(覆盖 socket 与 in-place 未刷新场景)
try:
emit(
"user_message",
{
"message": guide_message,
"images": [],
"videos": [],
"media_refs": [],
"message_source": "compression_handoff",
"visibility": "compact",
"starts_work": True,
"metadata": {
"message_source": "compression_handoff",
"visibility": "compact",
"starts_work": True,
},
"conversation_id": normalized_id,
},
room=f"user_{username}",
)
except Exception as emit_exc:
debug_log(f"[Compression] 发送 user_message 事件失败: {emit_exc}")
except Exception as exc: except Exception as exc:
debug_log(f"[Compression] 追加引导语消息失败: {exc}") debug_log(f"[Compression] 追加引导语消息失败: {exc}")
response_payload["auto_task_started"] = False response_payload["auto_task_started"] = False

View File

@ -7,22 +7,14 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
SUMMARY_PROMPT = ( def _load_summary_prompt(web_terminal) -> str:
"由于当前对话过长,系统正在自动压缩。请你基于已有上下文输出一份可继续执行的工作总结,要求:\n" """从 prompts/deep_compression_summary.txt 加载压缩总结提示词。"""
"1) 任务目标与用户真实诉求\n" try:
"2) 已完成工作(按时间顺序)\n" return web_terminal.load_prompt("deep_compression_summary").strip()
"3) 关键决策与原因\n" except Exception:
"4) 修改过的文件与核心变更点\n" return (
"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]): def _emit(sender, event_type: str, payload: Dict[str, Any]):
@ -49,12 +41,18 @@ def _normalize_deep_compression_records(metadata: Dict[str, Any]) -> List[Dict[s
path = str(item.get("compact_file") or "").strip() path = str(item.get("compact_file") or "").strip()
if count <= 0 or not path: if count <= 0 or not path:
continue continue
try:
user_inputs_before = int(item.get("user_inputs_before", 0) or 0)
except Exception:
user_inputs_before = 0
normalized.append({ normalized.append({
"count": count, "count": count,
"compact_file": path, "compact_file": path,
"created_at": item.get("created_at"), "created_at": item.get("created_at"),
"source_conversation_id": item.get("source_conversation_id"), "source_conversation_id": item.get("source_conversation_id"),
"compressed_conversation_id": item.get("compressed_conversation_id"), "compressed_conversation_id": item.get("compressed_conversation_id"),
"user_inputs_before": user_inputs_before,
"summary": str(item.get("summary") or ""),
}) })
normalized.sort(key=lambda x: (int(x.get("count") or 0), str(x.get("created_at") or ""))) normalized.sort(key=lambda x: (int(x.get("count") or 0), str(x.get("created_at") or "")))
deduped: List[Dict[str, Any]] = [] deduped: List[Dict[str, Any]] = []
@ -68,19 +66,9 @@ def _normalize_deep_compression_records(metadata: Dict[str, Any]) -> List[Dict[s
return deduped return deduped
def _build_guide_message(*, compression_index: int, compact_file: str, previous_records: List[Dict[str, Any]]) -> str: def _build_guide_message(*, compression_index: int, compact_file: str) -> str:
"""生成文件模式的引导语:仅提示压缩文件位置,由模型自行阅读。""" """生成文件模式的引导语:仅提示压缩文件位置,由模型自行阅读。"""
base = GUIDE_USER_MESSAGE_TEMPLATE.format(count=compression_index, path=compact_file) return f"当前对话已经被第{compression_index}次压缩。请阅读 {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: def _read_compact_file_content(project_path: Path, relative_path: str) -> str:
@ -95,29 +83,96 @@ def _read_compact_file_content(project_path: Path, relative_path: str) -> str:
return "" return ""
def _read_summary_from_record(record: Dict[str, Any]) -> str:
"""从 deep_compression_records 条目中读取保存的总结内容。"""
summary = record.get("summary")
return str(summary).strip() if isinstance(summary, str) else ""
def _build_user_inputs_section(
user_inputs: List[str],
previous_records: List[Dict[str, Any]],
current_count: int,
) -> str:
"""构建'用户的所有输入'区块,按历史压缩轮次插入分段标记。"""
if not user_inputs:
return "(无)"
breakpoints: Dict[int, int] = {}
for rec in previous_records:
count = int(rec.get("count") or 0)
before = int(rec.get("user_inputs_before") or 0)
if count > 0 and before > 0:
breakpoints[before] = count
sorted_breaks = sorted(breakpoints.items(), key=lambda x: x[0])
break_iter = iter(sorted_breaks)
next_break = next(break_iter, None)
lines: List[str] = []
last_break_index = 0
for idx, text in enumerate(user_inputs, start=1):
lines.append(f"{idx}. {text}")
if next_break and idx == next_break[0]:
lines.append(f"<第{next_break[1]}次压缩>")
last_break_index = idx
next_break = next(break_iter, None)
# 当前压缩之后还有新增输入时,追加当前压缩标记
if len(user_inputs) > last_break_index:
lines.append(f"<当前触发的第{current_count}次压缩>")
return "\n".join(lines)
def _build_summaries_section(
previous_records: List[Dict[str, Any]],
current_summary: str,
current_count: int,
) -> List[str]:
"""构建'历次压缩总结'区块,按顺序列出每次压缩的总结。"""
lines: List[str] = []
for rec in previous_records:
count = int(rec.get("count") or 0)
if count <= 0:
continue
summary = _read_summary_from_record(rec)
lines.append(f"### 第{count}次的总结")
lines.append(summary or "(读取失败)")
lines.append("")
lines.append(f"### 第{current_count}次的总结")
lines.append(current_summary or "(生成失败)")
return lines
def _build_inject_guide_message( def _build_inject_guide_message(
*, *,
project_path: Path,
compression_index: int, compression_index: int,
current_record: Dict[str, Any], current_record: Dict[str, Any],
previous_records: List[Dict[str, Any]], previous_records: List[Dict[str, Any]],
user_inputs: List[str],
latest_user_input: str,
) -> str: ) -> str:
"""生成直接注入模式的引导语:把历次压缩文件全文按顺序拼入正文,不提及文件位置。""" """生成直接注入模式的引导语:把历次压缩总结、用户输入按顺序拼入正文。"""
lines: List[str] = [f"当前对话已被第{compression_index}次压缩,以下为历次压缩的完整工作总结,请据此继续工作。"] lines: List[str] = [
all_records = list(previous_records) + [current_record] f"当前对话已被第{compression_index}次压缩。以下为按时间顺序汇总的用户输入、历次压缩总结以及最近一次输入,请据此继续工作。",
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: lines.append(_build_user_inputs_section(user_inputs, previous_records, compression_index))
continue lines.append("")
content = _read_compact_file_content(project_path, rel_path) lines.append("历次压缩总结")
lines.append("") lines.append("")
lines.append(f"{count}次压缩:") summary_lines = _build_summaries_section(
lines.append(content or "(压缩文件内容读取失败)") previous_records,
current_record.get("summary", ""),
compression_index,
)
lines.extend(summary_lines)
lines.append("")
lines.append("用户的最近一次输入:")
if (latest_user_input or "").strip():
lines.append(latest_user_input.strip())
else:
lines.append("(无)")
return "\n".join(lines).strip() return "\n".join(lines).strip()
def _extract_text_only(content: Any) -> str: def _extract_text_only(content: Any) -> str:
if content is None: if content is None:
return "" return ""
@ -136,48 +191,6 @@ def _extract_text_only(content: Any) -> str:
return str(content) 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]: def _collect_user_texts(messages: List[Dict[str, Any]]) -> List[str]:
result: List[str] = [] result: List[str] = []
for msg in messages: for msg in messages:
@ -230,45 +243,31 @@ def _write_compact_file(
compression_index: int, compression_index: int,
summary_text: str, summary_text: str,
user_inputs: List[str], user_inputs: List[str],
last_tools: List[Dict[str, Any]],
latest_user_input: str, latest_user_input: str,
previous_records: List[Dict[str, Any]],
) -> str: ) -> str:
compact_dir = project_path / ".agents" / "compact_result" compact_dir = project_path / ".agents" / "compact_result"
compact_dir.mkdir(parents=True, exist_ok=True) compact_dir.mkdir(parents=True, exist_ok=True)
filename = f"compact_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{compression_index:03d}.md" filename = f"compact_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{compression_index:03d}.md"
file_path = compact_dir / filename file_path = compact_dir / filename
lines: List[str] = [ lines: List[str] = [
f"# 对话已被第{compression_index}次压缩", f"# 对话已被第{compression_index}次压缩",
"", "",
"## 工作总结", "## 用户的所有输入",
summary_text or "生成总结失败",
"",
"## 用户的所有输入(仅文字)",
] ]
if user_inputs: lines.append(_build_user_inputs_section(user_inputs, previous_records, compression_index))
for idx, text in enumerate(user_inputs, start=1): lines.append("")
lines.append(f"{idx}. {text}") lines.append("## 历次压缩总结")
else: lines.append("")
lines.append("- (无)") summary_lines = _build_summaries_section(
lines.extend(["", "## 最近5条工具调用参数 + 结果)"]) previous_records,
if last_tools: summary_text,
for idx, item in enumerate(last_tools, start=1): compression_index,
lines.extend([ )
f"### {idx}. {item.get('tool_name')}", lines.extend(summary_lines)
f"- tool_call_id: {item.get('tool_call_id')}", lines.append("")
"- 参数:", lines.append("## 用户最新的一次输入")
"```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(): if (latest_user_input or "").strip():
lines.append(latest_user_input.strip()) lines.append(latest_user_input.strip())
else: else:
@ -322,7 +321,7 @@ async def run_deep_compression(
return {"success": False, "error": "对话正在压缩中", "in_progress": True} return {"success": False, "error": "对话正在压缩中", "in_progress": True}
# 读取个性化压缩设置: # 读取个性化压缩设置:
# - compress_form: file生成文件引导语提示位置 / inject把历次压缩文件全文注入引导语) # - compress_form: file生成文件引导语提示位置 / inject把历次压缩内容注入引导语)
# - compress_behavior: continue注入引导语并触发请求 / wait仅注入引导语等待用户 # - compress_behavior: continue注入引导语并触发请求 / wait仅注入引导语等待用户
# compress_behavior 仅作用于手动压缩;自动深压缩永远继续工作。 # compress_behavior 仅作用于手动压缩;自动深压缩永远继续工作。
try: try:
@ -368,7 +367,7 @@ async def run_deep_compression(
"job_id": job_id, "job_id": job_id,
}) })
summary_text, summary_fail_reason = await _generate_summary(web_terminal, SUMMARY_PROMPT, retries=5) summary_text, summary_fail_reason = await _generate_summary(web_terminal, _load_summary_prompt(web_terminal), retries=5)
if summary_fail_reason: if summary_fail_reason:
_emit(sender, "system_message", {"content": f"自动压缩总结失败,将使用失败占位文本:{summary_fail_reason}"}) _emit(sender, "system_message", {"content": f"自动压缩总结失败,将使用失败占位文本:{summary_fail_reason}"})
@ -389,14 +388,15 @@ async def run_deep_compression(
messages = conv_data.get("messages") or [] messages = conv_data.get("messages") or []
user_inputs = _collect_user_texts(messages) user_inputs = _collect_user_texts(messages)
latest_user_input = user_inputs[-1] if user_inputs else "" latest_user_input = user_inputs[-1] if user_inputs else ""
last_tools = _collect_last_tool_entries(messages, limit=5, max_content_chars=3000) user_inputs_before = len(user_inputs)
relative_compact_path = _write_compact_file( relative_compact_path = _write_compact_file(
Path(workspace.project_path), Path(workspace.project_path),
compression_index=target_count, compression_index=target_count,
summary_text=summary_text, summary_text=summary_text,
user_inputs=user_inputs, user_inputs=user_inputs,
last_tools=last_tools,
latest_user_input=latest_user_input, latest_user_input=latest_user_input,
previous_records=previous_records,
) )
cm.set_compression_state( cm.set_compression_state(
@ -407,7 +407,6 @@ async def run_deep_compression(
) )
# === in-place 压缩:不创建/切换新对话,只把当前对话历史前缀打上 deep_compacted 标记 === # === in-place 压缩:不创建/切换新对话,只把当前对话历史前缀打上 deep_compacted 标记 ===
# 当前对话已在函数开头对齐为目标对话,这里直接对内存历史打标。
now_iso = datetime.now().isoformat() now_iso = datetime.now().isoformat()
marked_count = _mark_history_compacted( marked_count = _mark_history_compacted(
cm.conversation_history or [], cm.conversation_history or [],
@ -439,31 +438,40 @@ async def run_deep_compression(
"created_at": now_iso, "created_at": now_iso,
"source_conversation_id": conversation_id, "source_conversation_id": conversation_id,
"compressed_conversation_id": conversation_id, "compressed_conversation_id": conversation_id,
"user_inputs_before": user_inputs_before,
"summary": summary_text,
} }
all_records = previous_records + [current_record] all_records = previous_records + [current_record]
# 构建引导语(按压缩形式)。inject 模式读取历次压缩文件全文,文件仍会生成,只是不提及位置。 # 构建引导语(按压缩形式)。
if compress_form == "inject": if compress_form == "inject":
guide_message = _build_inject_guide_message( guide_message = _build_inject_guide_message(
project_path=Path(workspace.project_path),
compression_index=target_count, compression_index=target_count,
current_record=current_record, current_record=current_record,
previous_records=previous_records, previous_records=previous_records,
user_inputs=user_inputs,
latest_user_input=latest_user_input,
) )
else: else:
guide_message = _build_guide_message( guide_message = _build_guide_message(
compression_index=target_count, compression_index=target_count,
compact_file=relative_compact_path, compact_file=relative_compact_path,
previous_records=previous_records,
) )
# 更新对话 metadata压缩记录 + 清理压缩状态标记(同一对话,无切换)。 # 更新对话 metadata压缩记录 + 清理压缩状态标记(同一对话,无切换)。
# 同时清除需要重建的 frozen prompt 缓存,使压缩后下一次请求自动重新加载动态内容。 # 同时清除 frozen prompt 缓存,使压缩后下一次请求自动重新加载动态内容。
REBUILD_FROZEN_KEYS = ( REBUILD_FROZEN_KEYS = (
"frozen_skills_prompt", "frozen_main_system_prompt",
"frozen_workspace_prompt", "frozen_permission_prompt",
"frozen_execution_prompt",
"frozen_recent_conversations_prompt",
"frozen_personalization_prompt", "frozen_personalization_prompt",
"frozen_workspace_prompt",
"frozen_agents_md_prompt",
"frozen_skills_prompt",
"frozen_memory_prompt", "frozen_memory_prompt",
"frozen_custom_system_prompt",
"frozen_disabled_tools_prompt",
) )
meta_updates = { meta_updates = {
"compression_count": target_count, "compression_count": target_count,
@ -511,4 +519,3 @@ async def run_deep_compression(
"summary_failed": bool(summary_fail_reason), "summary_failed": bool(summary_fail_reason),
"guide_message": guide_message, "guide_message": guide_message,
} }

View File

@ -1041,7 +1041,7 @@ export const messageMethods = {
const confirmed = await this.confirmAction({ const confirmed = await this.confirmAction({
title: '压缩对话', title: '压缩对话',
message: '确定要压缩当前对话记录吗?压缩后会生成新的对话副本。', message: '确定要压缩当前对话记录吗?较早的消息会被折叠并生成压缩摘要,当前对话 ID 保持不变。',
confirmText: '压缩', confirmText: '压缩',
cancelText: '取消' cancelText: '取消'
}); });
@ -1102,9 +1102,11 @@ export const messageMethods = {
this.monitorShowPendingReply(); this.monitorShowPendingReply();
} }
} else if (compressBehavior === 'wait') { } else if (compressBehavior === 'wait') {
// 等待用户:后端已把引导语作为 user 消息追加进历史,这里刷新展示,不触发请求。 // 等待用户:后端已把引导语作为 user 消息追加进历史,这里刷新展示,不触发请求。
if (newId && !isInPlace) { if (newId && !isInPlace) {
await this.loadConversation(newId, { force: true }); await this.loadConversation(newId, { force: true });
} else if (isInPlace) {
await this.loadConversation(this.currentConversationId, { force: true });
} }
} else if (guideMessage) { } else if (guideMessage) {
await this.sendAutoUserMessage(guideMessage); await this.sendAutoUserMessage(guideMessage);

View File

@ -105,7 +105,7 @@ class ServerRefactorSmokeTest(unittest.TestCase):
thinking_mode = False thinking_mode = False
model_key = None model_key = None
def apply_personalization_preferences(self, config): def apply_personalization_preferences(self, config, **kwargs):
calls.append(config) calls.append(config)
workspace = SimpleNamespace(data_dir="/tmp/workspace-data") workspace = SimpleNamespace(data_dir="/tmp/workspace-data")