agent-Specialization/server/deep_compression.py
JOJO c80bc4fbb4 feat(api): 支持 x-opencode-session 会话头与 Astrion User-Agent
- OpenCode Go/Zen 自 2026-09-05 起要求每个对话携带稳定的 x-opencode-session 头,用于会话亲和路由与 prompt 缓存优化
- 个人空间「模型与思考」新增「外部会话标识」开关(默认关闭,opt-in)
- 开启后按对话惰性生成随机 ID(uuid4 hex)并持久化到对话 metadata,深压缩后重置
- 覆盖主对话/传统与多智能体子智能体/三个审核智能体(一次性 ID)/标题生成(复用主对话 ID)
- 仅对 opencode.ai 域名下发,不向其他 provider 泄露对话标识
- 所有对外模型请求统一携带 User-Agent: Astrion/1.0(新增 config/version.py)
2026-09-07 18:50:29 +08:00

691 lines
28 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
import os
from datetime import datetime
from functools import wraps
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from modules.external_session import new_external_session_id
from modules.i18n import tr
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 _build_summary_prompt(template: str, *, compression_index: int, latest_user_input: str) -> str:
"""把压缩轮次、最新用户输入、增量条目占位符替换进总结提示词模板。
- {compression_index}: 当前压缩轮次(总是替换)
- {latest_user_input}: 最新一条真实用户输入原文
- {incremental_item}: 第2次压缩起插入增量总结条目首次压缩替换为空
"""
prompt = template or ""
latest = (latest_user_input or "").strip() or "(无)"
if compression_index > 1:
incremental_item = (
"9) 自上次压缩之后到现在新完成的工作:单独列出这段时间内新完成的工作与关键结果,"
"此前已被历次压缩总结覆盖的内容不必重复。\n"
)
else:
incremental_item = ""
prompt = prompt.replace("{compression_index}", str(compression_index))
prompt = prompt.replace("{latest_user_input}", latest)
prompt = prompt.replace("{incremental_item}", incremental_item)
return prompt
def _emit(sender, event_type: str, payload: Dict[str, Any]):
if not callable(sender):
return
try:
sender(event_type, payload)
except Exception:
pass
def _clear_compression_state_on_error(func):
"""压缩过程异常退出时清理持久化的 compression_in_progress 标记。
压缩中途异常(或调用侧取消)若不清标记,对话 metadata 会永久残留
in_progress=True前端据此锁输入栏/拦切换对话(只能删对话解决)。
"""
@wraps(func)
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except Exception:
try:
web_terminal = kwargs.get("web_terminal")
conversation_id = kwargs.get("conversation_id")
cm = getattr(web_terminal, "context_manager", None)
if cm is not None and conversation_id:
if getattr(cm, "current_conversation_id", None) == conversation_id:
cm.set_compression_state(in_progress=False)
else:
target_manager = (
cm._get_conversation_manager_for_id(conversation_id)
if hasattr(cm, "_get_conversation_manager_for_id")
else cm.conversation_manager
)
target_manager.update_conversation_metadata(conversation_id, {
"compression_in_progress": False,
"compression_mode": None,
"compression_stage": None,
"compression_job_id": None,
"compression_resume_payload": None,
"compression_pid": None,
})
except Exception:
pass
raise
return wrapper
def heal_stale_compression_flag(
target_manager,
conversation_id: str,
metadata: Dict[str, Any],
context_manager=None,
) -> bool:
"""读取压缩进行态并懒清理残留标记,返回有效的 in_progress。
compression_in_progress 持久化在对话 metadata 中。进程在压缩中途被杀时标记
残留为 True压缩随进程死亡不可能仍在进行会导致前端误以为仍在压缩。
判定依据:标记写入时记录了发起进程的 pidcompression_pid
compression_mixin.set_compression_state与当前进程 pid 不一致(或缺失,
即旧版本写入的标记)即判定为残留并清除(磁盘 + 内存双清)。
"""
if not bool((metadata or {}).get("compression_in_progress", False)):
return False
flag_pid = (metadata or {}).get("compression_pid")
if flag_pid is not None and flag_pid == os.getpid():
return True
clear_updates = {
"compression_in_progress": False,
"compression_mode": None,
"compression_stage": None,
"compression_job_id": None,
"compression_resume_payload": None,
"compression_pid": None,
}
try:
target_manager.update_conversation_metadata(conversation_id, clear_updates)
except Exception:
pass
# 同步内存中的 metadata该对话若正被加载工具循环的
# is_compression_in_progress() 读的是内存副本,不清理会一直误判压缩中。
try:
if (
context_manager is not None
and getattr(context_manager, "current_conversation_id", None) == conversation_id
and isinstance(getattr(context_manager, "conversation_metadata", None), dict)
):
context_manager.conversation_metadata.update(clear_updates)
except Exception:
pass
return False
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
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]] = []
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 _collect_runtime_state_lines(web_terminal, conversation_id: str) -> List[str]:
"""收集压缩后模型续接所需的运行时状态。
- 子智能体编号:仅传统模式列出(该模式下编号由模型指定且重复会创建失败);
多智能体模式不列编号(模型可通过 list_active_sub_agents 工具查询活跃实例)。
- 持久终端:只列数量与名称。
"""
lines: List[str] = []
is_multi_agent = bool(getattr(web_terminal, "multi_agent_mode", False))
if not is_multi_agent:
used: List[int] = []
manager = getattr(web_terminal, "sub_agent_manager", None)
if manager is not None:
try:
agents_map = getattr(manager, "conversation_agents", None) or {}
raw_used = agents_map.get(conversation_id) or []
used = sorted(int(x) for x in raw_used)
except Exception:
used = []
if used:
next_id = max(used) + 1
used_text = "".join(str(x) for x in used)
lines.append(
f"- 子智能体编号:本对话已使用编号 {used_text},下一个可用编号为 {next_id}"
"新建子智能体时请使用该编号,重复使用已用编号会创建失败。"
)
else:
lines.append("- 子智能体编号:本对话尚未创建过子智能体,下一个可用编号为 1。")
terminal_names: List[str] = []
terminal_manager = getattr(web_terminal, "terminal_manager", None)
if terminal_manager is not None:
try:
terminals = getattr(terminal_manager, "terminals", None) or {}
terminal_names = [str(name) for name in terminals.keys()]
except Exception:
terminal_names = []
if terminal_names:
names_text = "".join(terminal_names)
lines.append(f"- 持久终端:当前共有 {len(terminal_names)} 个终端会话:{names_text}")
else:
lines.append("- 持久终端:当前没有终端会话。")
return lines
def _build_guide_message(*, compression_index: int, compact_file: str) -> str:
"""生成文件模式的引导语:仅提示压缩文件位置,由模型自行阅读。"""
return f"当前对话已经被第{compression_index}次压缩。请阅读 {compact_file} 并继续工作。"
def _read_compact_file_content(project_path: Path, relative_path: str) -> str:
"""读取 compact 文件全文,读取失败时返回空串。"""
rel = str(relative_path or "").strip()
if not rel:
return ""
try:
target = (Path(project_path) / rel).resolve()
return target.read_text(encoding="utf-8").strip()
except Exception:
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(
*,
compression_index: int,
current_record: Dict[str, Any],
previous_records: List[Dict[str, Any]],
user_inputs: List[str],
runtime_state_lines: Optional[List[str]] = None,
) -> str:
"""生成直接注入模式的引导语:把历次压缩总结、用户输入按顺序拼入正文。"""
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)
if runtime_state_lines:
lines.append("")
lines.append("当前运行时状态")
lines.extend(runtime_state_lines)
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 _collect_user_texts(messages: List[Dict[str, Any]]) -> List[str]:
# 口径:用户亲手输入 = message_source 为 'user'(直接发送)或 'presend'(提前输入排队后转正),
# 字段缺失的老消息视为 'user'guidance手动引导/notify/compression 等运行期注入消息不计入。
# 与前端左侧输入导航 isRealUserNavMessageChatArea.vue口径保持一致。
result: List[str] = []
for msg in messages:
if msg.get("role") != "user":
continue
metadata = msg.get("metadata") if isinstance(msg.get("metadata"), dict) else {}
source = str(metadata.get("message_source") or "user").strip().lower()
if source not in ("user", "presend"):
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}]
# 关键:总结请求必须与"用户正常发一条消息"完全无差别,才能 100% 命中前缀缓存。
# 因此 tools 必须照常传入(缺失 tools 字段会改变请求前缀、破坏缓存)。
# 模型即便返回 tool_calls 也会被忽略——我们只取 contentprompt 已要求其直接输出总结。
tools = web_terminal.define_tools()
for _ in range(max(1, retries)):
try:
response_text = ""
async for chunk in web_terminal.api_client.chat(messages, tools=tools, 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 = tr("deep_compression.empty_model_content")
except Exception as exc:
last_reason = str(exc)
await asyncio.sleep(0.2)
return tr("deep_compression.summary_failed", reason=last_reason or tr("deep_compression.unknown_reason")), last_reason
def _write_compact_file(
project_path: Path,
*,
compression_index: int,
summary_text: str,
user_inputs: List[str],
previous_records: List[Dict[str, Any]],
runtime_state_lines: Optional[List[str]] = None,
) -> str:
compact_dir = project_path / ".astrion" / "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}次压缩",
"",
"## 用户的所有输入",
]
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)
if runtime_state_lines:
lines.append("")
lines.append("## 当前运行时状态")
lines.extend(runtime_state_lines)
file_path.write_text("\n".join(lines).strip() + "\n", encoding="utf-8")
return str(file_path.relative_to(project_path))
def _mark_history_compacted(history: List[Dict[str, Any]], *, round_index: int, now: str) -> int:
"""把当前对话历史中所有尚未标记的消息打上 deep_compacted 标记in-place
深压缩按"整段前缀"处理:本次压缩时,历史里所有还没被标记的消息整体视为已压缩前缀。
这天然保证 assistant.tool_calls 与其 tool 响应被一并标记/排除,不会出现配对悬空。
原文保留在 metadata 中,持久化与重新加载时照常显示,仅在 build_messages 构建请求时被跳过。
返回本次新标记的消息条数。
"""
if not isinstance(history, list):
return 0
marked = 0
for msg in history:
if not isinstance(msg, dict):
continue
metadata = msg.get("metadata")
if not isinstance(metadata, dict):
metadata = {}
if metadata.get("deep_compacted"):
continue
metadata["deep_compacted"] = True
metadata["deep_compacted_round"] = round_index
metadata["deep_compacted_at"] = now
msg["metadata"] = metadata
marked += 1
return marked
@_clear_compression_state_on_error
async def run_deep_compression(
*,
web_terminal,
workspace,
conversation_id: str,
mode: str,
sender=None,
) -> Dict[str, Any]:
cm = web_terminal.context_manager
target_manager = (
cm._get_conversation_manager_for_id(conversation_id)
if hasattr(cm, "_get_conversation_manager_for_id")
else cm.conversation_manager
)
conv_data = target_manager.load_conversation(conversation_id)
if not conv_data:
return {"success": False, "error": tr("deep_compression.conversation_not_found", conversation_id=conversation_id)}
metadata = conv_data.get("metadata", {}) or {}
if metadata.get("compression_in_progress"):
return {"success": False, "error": tr("deep_compression.in_progress"), "in_progress": True}
# 读取个性化压缩设置:
# - compress_form: file生成文件引导语提示位置 / inject把历次压缩内容注入引导语
try:
from modules.personalization_manager import load_personalization_config
pconfig = load_personalization_config(workspace.data_dir) or {}
except Exception:
pconfig = {}
compress_form = str(pconfig.get("deep_compress_form") or "file").strip().lower()
if compress_form not in ("file", "inject"):
compress_form = "file"
# compress_behavior 固定规则(无个性化开关):
# - 手动压缩:只生成压缩消息(引导语),不自动续接,等待用户继续发送消息才工作。
# - 自动深压缩:当前任务尚未完成才触发,压缩后必须继续工作。
compress_behavior = "wait" if mode == "manual" else "continue"
# in-place 压缩全程操作"当前对话"的内存历史:总结、标记、状态写入都依赖
# cm.conversation_history / cm.conversation_metadata。若当前对话不是目标对话
# 必须先切换过去,否则总结会基于错误历史、压缩状态也会写错对话。
if getattr(cm, "current_conversation_id", None) != conversation_id:
try:
web_terminal.load_conversation(conversation_id)
except Exception as exc:
return {"success": False, "error": tr("deep_compression.load_failed", error=exc)}
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,
})
# 提前收集用户输入与运行时状态:总结提示词需要嵌入最新输入与压缩轮次,
# compact 文件 / 注入引导语需要追加运行时状态区块。
messages = conv_data.get("messages") or []
user_inputs = _collect_user_texts(messages)
latest_user_input = user_inputs[-1] if user_inputs else ""
user_inputs_before = len(user_inputs)
runtime_state_lines = _collect_runtime_state_lines(web_terminal, conversation_id)
summary_prompt = _build_summary_prompt(
_load_summary_prompt(web_terminal),
compression_index=target_count,
latest_user_input=latest_user_input,
)
summary_text, summary_fail_reason = await _generate_summary(web_terminal, summary_prompt, retries=5)
if summary_fail_reason:
_emit(sender, "system_message", {"content": tr("deep_compression.summary_failed_notice", reason=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,
})
relative_compact_path = _write_compact_file(
Path(workspace.project_path),
compression_index=target_count,
summary_text=summary_text,
user_inputs=user_inputs,
previous_records=previous_records,
runtime_state_lines=runtime_state_lines,
)
cm.set_compression_state(
in_progress=True,
mode=mode,
stage="marking_history",
job_id=job_id,
)
# === in-place 压缩:不创建/切换新对话,只把当前对话历史前缀打上 deep_compacted 标记 ===
now_iso = datetime.now().isoformat()
marked_count = _mark_history_compacted(
cm.conversation_history or [],
round_index=target_count,
now=now_iso,
)
# 标记后立即持久化历史(标记写在每条消息的 metadata 中)。
try:
cm.save_current_conversation()
except Exception as exc:
_emit(sender, "system_message", {"content": tr("deep_compression.marks_save_failed", error=exc)})
# 关键:重置 current_context_tokens避免自动压缩续接后阈值判断仍读到压缩前的大值而陷入死循环。
# 真实上下文长度会在下一次 API 响应后被重新写入。
# 同时置位 cache_cold_start_pending压缩重写了上下文前缀缓存可能已失效
# 下一次真实调用若未命中缓存其输入会被计入冷启动豁免值cache_exempt_input_tokens
try:
target_manager.update_token_statistics(
conversation_id,
input_tokens=0,
output_tokens=0,
total_tokens=0,
current_context_tokens=0,
cache_cold_start_pending=True,
)
except Exception as exc:
_emit(sender, "system_message", {"content": tr("deep_compression.stats_reset_failed", error=exc)})
current_record = {
"count": target_count,
"compact_file": relative_compact_path,
"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]
# 构建引导语(按压缩形式)。
if compress_form == "inject":
guide_message = _build_inject_guide_message(
compression_index=target_count,
current_record=current_record,
previous_records=previous_records,
user_inputs=user_inputs,
runtime_state_lines=runtime_state_lines,
)
else:
guide_message = _build_guide_message(
compression_index=target_count,
compact_file=relative_compact_path,
)
# 更新对话 metadata压缩记录 + 清理压缩状态标记(同一对话,无切换)。
# 同时清除 frozen prompt 缓存,使压缩后下一次请求自动重新加载动态内容。
REBUILD_FROZEN_KEYS = (
"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,
"deep_compression_records": all_records,
"last_deep_compression_record": current_record,
"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": False,
# 压缩重写上下文后旧 session 的缓存亲和已失效,重置外部会话标识
# (与 frozen prompt 一样随压缩周期重建;开关关闭时该值不会被发送)
"external_session_id": new_external_session_id(),
}
for frozen_key in REBUILD_FROZEN_KEYS:
meta_updates[frozen_key] = None
target_manager.update_conversation_metadata(conversation_id, meta_updates)
# 同步内存中的 metadata清除 frozen 缓存
try:
if getattr(cm, "current_conversation_id", None) == conversation_id and isinstance(cm.conversation_metadata, dict):
for frozen_key in REBUILD_FROZEN_KEYS:
cm.conversation_metadata.pop(frozen_key, None)
cm.conversation_metadata.update(meta_updates)
except Exception:
pass
_emit(sender, "compression_finished", {
"source_conversation_id": conversation_id,
"conversation_id": conversation_id,
"in_progress": False,
"compact_file": relative_compact_path,
"marked_count": marked_count,
"compress_form": compress_form,
"compress_behavior": compress_behavior,
"job_id": job_id,
})
return {
"success": True,
"in_place": True,
"compressed_conversation_id": conversation_id,
"compact_file": relative_compact_path,
"marked_count": marked_count,
"compress_form": compress_form,
"compress_behavior": compress_behavior,
"summary_failed": bool(summary_fail_reason),
"guide_message": guide_message,
}