From f75e2f07a35f49d1e37299eb94134273b8872ac1 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Thu, 18 Jun 2026 14:50:16 +0800 Subject: [PATCH] =?UTF-8?q?refactor(deep=5Fcompression):=20=E6=94=B9?= =?UTF-8?q?=E9=80=A0=E6=B7=B1=E5=BA=A6=E5=8E=8B=E7=BC=A9=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E3=80=81=E6=8F=90=E7=A4=BA=E8=AF=8D=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E4=B8=8E=E5=89=8D=E7=AB=AF=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 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 中误导性的主提示词构建参数 --- core/main_terminal_parts/context.py | 13 +- prompts/deep_compression_summary.txt | 17 ++ server/conversation.py | 23 +++ server/deep_compression.py | 257 ++++++++++++++------------- static/src/app/methods/message.ts | 6 +- test/test_server_refactor_smoke.py | 2 +- 6 files changed, 178 insertions(+), 140 deletions(-) create mode 100644 prompts/deep_compression_summary.txt diff --git a/core/main_terminal_parts/context.py b/core/main_terminal_parts/context.py index e07f5cf..26eee5c 100644 --- a/core/main_terminal_parts/context.py +++ b/core/main_terminal_parts/context.py @@ -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", "") ) diff --git a/prompts/deep_compression_summary.txt b/prompts/deep_compression_summary.txt new file mode 100644 index 0000000..de7cb56 --- /dev/null +++ b/prompts/deep_compression_summary.txt @@ -0,0 +1,17 @@ +由于当前对话过长,系统正在自动压缩。请你基于已有上下文输出一份可继续执行的工作总结,要求: +1) 任务目标与用户真实诉求:用户到底想让你完成什么。 +2) 已完成工作:按时间顺序列出关键步骤,包含涉及文件和核心结果。 +3) 关键决策与原因:为什么这样选,而不是别的方案。 +4) 修改过的文件与核心变更点:具体到文件路径和改动概要。 +5) 工具调用中的重要结果/错误与修复:哪些尝试失败过,最终怎么解决的。 +6) 风险与注意事项:继续工作时需要规避的问题。 +7) 当前正在执行的任务与进度:正在做什么、做到什么程度、卡在哪里。 +8) 下一步具体行动:必须足够具体,至少包含以下信息: + - 如果要读取文件,列出具体文件路径。 + - 如果要搜索,列出搜索关键词和范围。 + - 如果要修改文件,说明文件路径和预期改动。 + - 如果要运行命令,列出具体命令。 + - 如果要验证,说明验证方式和预期结果。 +请使用中文,结构清晰,尽量具体,不要省略关键上下文。 +不要考虑过往对话,只考虑当前对话任务。 +禁止调用任何工具,必须直接输出总结内容。 \ No newline at end of file diff --git a/server/conversation.py b/server/conversation.py index 34b54ec..1d7e88d 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -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 diff --git a/server/deep_compression.py b/server/deep_compression.py index 4fb316b..c80691d 100644 --- a/server/deep_compression.py +++ b/server/deep_compression.py @@ -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, } - diff --git a/static/src/app/methods/message.ts b/static/src/app/methods/message.ts index 840b192..874245e 100644 --- a/static/src/app/methods/message.ts +++ b/static/src/app/methods/message.ts @@ -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); diff --git a/test/test_server_refactor_smoke.py b/test/test_server_refactor_smoke.py index 097432f..82130d0 100644 --- a/test/test_server_refactor_smoke.py +++ b/test/test_server_refactor_smoke.py @@ -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")