Introduce workspace-level goal state persistence, goal prompt injection, and after-turn review handling so an active task can continue until the configured completion conditions are met. Add a dedicated goal review agent with readonly and active evidence modes, configurable model settings, review prompt, token/turn boundaries, idle-no-tool protection, and progress/completed/stopped events. Wire goal_mode through task creation, task restoration, compression handoff, runtime user messages, API message sanitization, and tool-call ordering so goal continuations survive long-running tasks and deep compression. Add Vue UI for arming goal mode from the quick menu, showing running/completed banners, displaying progress metrics, restoring running goal state, and exposing personalization settings for review mode and stop limits. Include goal mode research notes and default goal review configuration.
437 lines
16 KiB
Python
437 lines
16 KiB
Python
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any, Dict, List, Optional, Tuple
|
||
|
||
from modules.versioning_manager import ConversationVersioningManager
|
||
|
||
|
||
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
|
||
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}]
|
||
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 / ".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))
|
||
|
||
|
||
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 "未命名"
|
||
source_versioning = metadata.get("versioning") if isinstance(metadata.get("versioning"), dict) else {}
|
||
source_versioning_enabled = bool(source_versioning.get("enabled"))
|
||
source_tracking_mode = ConversationVersioningManager.normalize_tracking_mode(
|
||
source_versioning.get("tracking_mode")
|
||
)
|
||
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,
|
||
},
|
||
)
|
||
|
||
if source_versioning_enabled:
|
||
try:
|
||
vm = ConversationVersioningManager(
|
||
project_path=workspace.project_path,
|
||
data_dir=workspace.data_dir,
|
||
conversation_id=new_conv_id,
|
||
)
|
||
vm_meta = vm.set_enabled(
|
||
enabled=True,
|
||
mode="overwrite",
|
||
tracking_mode=source_tracking_mode,
|
||
)
|
||
new_conv_data = cm.conversation_manager.load_conversation(new_conv_id) or {}
|
||
snapshot_payload = {
|
||
"conversation_id": new_conv_id,
|
||
"title": new_conv_data.get("title"),
|
||
"metadata": new_conv_data.get("metadata") or {},
|
||
"messages": new_conv_data.get("messages") or [],
|
||
"message_index": -1,
|
||
"run_status": "initial",
|
||
}
|
||
init_result = vm.ensure_initial_checkpoint(
|
||
workspace_path=str(workspace.project_path),
|
||
conversation_snapshot=snapshot_payload,
|
||
tracking_mode=source_tracking_mode,
|
||
)
|
||
init_row = init_result.get("row") or {}
|
||
last_commit = init_row.get("commit") or vm_meta.get("last_commit")
|
||
cm.conversation_manager.update_conversation_metadata(
|
||
new_conv_id,
|
||
{
|
||
"versioning": {
|
||
"enabled": True,
|
||
"mode": "overwrite",
|
||
"tracking_mode": source_tracking_mode,
|
||
"updated_at": datetime.now().isoformat(),
|
||
"last_commit": last_commit,
|
||
"last_input_seq": int(vm_meta.get("last_input_seq") or 0),
|
||
}
|
||
},
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
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", {
|
||
"source_conversation_id": conversation_id,
|
||
"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,
|
||
}
|