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 json
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Set
@ -380,18 +379,8 @@ class MainTerminalContextMixin:
def _build_main_system_prompt() -> str:
system_prompt_template = self.load_prompt(prompt_name)
container_path = self.container_mount_path or "/workspace"
container_cpus = self.container_cpu_limit
container_memory = self.container_memory_limit
project_storage = self.project_storage_limit
# main_system.txt / main_system_qwenvl.txt 仅使用 {model_description}
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", "")
)

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["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:
debug_log(f"[Compression] 追加引导语消息失败: {exc}")
response_payload["auto_task_started"] = False

View File

@ -7,22 +7,14 @@ 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 _load_summary_prompt(web_terminal) -> str:
"""从 prompts/deep_compression_summary.txt 加载压缩总结提示词。"""
try:
return web_terminal.load_prompt("deep_compression_summary").strip()
except Exception:
return (
"由于当前对话过长,系统正在自动压缩。请你基于已有上下文输出一份可继续执行的工作总结。"
)
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()
if count <= 0 or not path:
continue
try:
user_inputs_before = int(item.get("user_inputs_before", 0) or 0)
except Exception:
user_inputs_before = 0
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"),
"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 "")))
deduped: List[Dict[str, Any]] = []
@ -68,19 +66,9 @@ def _normalize_deep_compression_records(metadata: Dict[str, Any]) -> List[Dict[s
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)
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()
return f"当前对话已经被第{compression_index}次压缩。请阅读 {compact_file} 并继续工作。"
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 ""
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(
*,
project_path: Path,
compression_index: int,
current_record: Dict[str, Any],
previous_records: List[Dict[str, Any]],
user_inputs: List[str],
latest_user_input: str,
) -> 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 "(压缩文件内容读取失败)")
"""生成直接注入模式的引导语:把历次压缩总结、用户输入按顺序拼入正文。"""
lines: List[str] = [
f"当前对话已被第{compression_index}次压缩。以下为按时间顺序汇总的用户输入、历次压缩总结以及最近一次输入,请据此继续工作。",
"",
"用户的所有输入",
]
lines.append(_build_user_inputs_section(user_inputs, previous_records, compression_index))
lines.append("")
lines.append("历次压缩总结")
lines.append("")
summary_lines = _build_summaries_section(
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()
def _extract_text_only(content: Any) -> str:
if content is None:
return ""
@ -136,48 +191,6 @@ def _extract_text_only(content: Any) -> str:
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:
@ -230,45 +243,31 @@ def _write_compact_file(
compression_index: int,
summary_text: str,
user_inputs: List[str],
last_tools: List[Dict[str, Any]],
latest_user_input: str,
previous_records: List[Dict[str, Any]],
) -> 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(["", "## 用户最新的一次输入"])
lines.append(_build_user_inputs_section(user_inputs, previous_records, compression_index))
lines.append("")
lines.append("## 历次压缩总结")
lines.append("")
summary_lines = _build_summaries_section(
previous_records,
summary_text,
compression_index,
)
lines.extend(summary_lines)
lines.append("")
lines.append("## 用户最新的一次输入")
if (latest_user_input or "").strip():
lines.append(latest_user_input.strip())
else:
@ -322,7 +321,7 @@ async def run_deep_compression(
return {"success": False, "error": "对话正在压缩中", "in_progress": True}
# 读取个性化压缩设置:
# - compress_form: file生成文件引导语提示位置 / inject把历次压缩文件全文注入引导语)
# - compress_form: file生成文件引导语提示位置 / inject把历次压缩内容注入引导语)
# - compress_behavior: continue注入引导语并触发请求 / wait仅注入引导语等待用户
# compress_behavior 仅作用于手动压缩;自动深压缩永远继续工作。
try:
@ -368,7 +367,7 @@ async def run_deep_compression(
"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:
_emit(sender, "system_message", {"content": f"自动压缩总结失败,将使用失败占位文本:{summary_fail_reason}"})
@ -389,14 +388,15 @@ async def run_deep_compression(
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)
user_inputs_before = len(user_inputs)
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,
previous_records=previous_records,
)
cm.set_compression_state(
@ -407,7 +407,6 @@ async def run_deep_compression(
)
# === in-place 压缩:不创建/切换新对话,只把当前对话历史前缀打上 deep_compacted 标记 ===
# 当前对话已在函数开头对齐为目标对话,这里直接对内存历史打标。
now_iso = datetime.now().isoformat()
marked_count = _mark_history_compacted(
cm.conversation_history or [],
@ -439,31 +438,40 @@ async def run_deep_compression(
"created_at": now_iso,
"source_conversation_id": conversation_id,
"compressed_conversation_id": conversation_id,
"user_inputs_before": user_inputs_before,
"summary": summary_text,
}
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,
user_inputs=user_inputs,
latest_user_input=latest_user_input,
)
else:
guide_message = _build_guide_message(
compression_index=target_count,
compact_file=relative_compact_path,
previous_records=previous_records,
)
# 更新对话 metadata压缩记录 + 清理压缩状态标记(同一对话,无切换)。
# 同时清除需要重建的 frozen prompt 缓存,使压缩后下一次请求自动重新加载动态内容。
# 同时清除 frozen prompt 缓存,使压缩后下一次请求自动重新加载动态内容。
REBUILD_FROZEN_KEYS = (
"frozen_skills_prompt",
"frozen_workspace_prompt",
"frozen_main_system_prompt",
"frozen_permission_prompt",
"frozen_execution_prompt",
"frozen_recent_conversations_prompt",
"frozen_personalization_prompt",
"frozen_workspace_prompt",
"frozen_agents_md_prompt",
"frozen_skills_prompt",
"frozen_memory_prompt",
"frozen_custom_system_prompt",
"frozen_disabled_tools_prompt",
)
meta_updates = {
"compression_count": target_count,
@ -511,4 +519,3 @@ async def run_deep_compression(
"summary_failed": bool(summary_fail_reason),
"guide_message": guide_message,
}

View File

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

View File

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