package vip.mate.agent.context;
import vip.mate.llm.chatmodel.ChatModelDefaults;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.stereotype.Component;
import vip.mate.agent.graph.executor.ToolResultStorage;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.config.ConversationWindowProperties;
import vip.mate.memory.spi.MemoryManager;
import vip.mate.workspace.conversation.ConversationService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 会话历史上下文窗口管理器(四阶段压缩升级版)
*
* 四阶段压缩策略:
*
* - Soft Trim — 裁剪旧工具结果为 head+tail
* - Hard Clear — 替换所有旧工具结果为占位符
* - Pre-Prune — 喂给摘要 LLM 前清理工具输出(减少摘要输入 token)
* - LLM 结构化摘要 — Goal/Progress/Decisions/Files/NextSteps 模板,支持迭代更新
*
*
* 关键特性:
*
* - 迭代摘要更新:多轮压缩时将旧摘要 + 新轮次合并,信息不丢失
* - 动态 Token 预算:基于模型上下文长度计算尾部保护和摘要预算
* - 压缩冷却机制:摘要失败后 10 分钟内不重试,防止雪崩
* - MemoryProvider 钩子:压缩前通知记忆 provider 提取关键信息
*
*
* 安全设计:摘要内容作为 UserMessage 注入(非 SystemMessage),
* 避免历史用户输入被提升为系统级指令。
*
* @author MateClaw Team
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ConversationWindowManager {
// ==================== Prompt 模板 ====================
/** 首次压缩:结构化摘要系统提示 */
private static final String STRUCTURED_SUMMARY_SYSTEM = PromptLoader.loadPrompt("context/structured-summary-system");
/** 首次压缩:用户提示模板 */
private static final String STRUCTURED_SUMMARY_USER = PromptLoader.loadPrompt("context/structured-summary-user");
/** 迭代更新:合并旧摘要 + 新轮次 */
private static final String STRUCTURED_SUMMARY_UPDATE = PromptLoader.loadPrompt("context/structured-summary-update");
/** 摘要注入前缀 (package-private for test assertions) */
static final String SUMMARY_PREFIX =
"[上下文压缩] 更早的对话轮次已被压缩为摘要以节省上下文空间。" +
"以下摘要描述了已完成的工作,当前会话状态可能已反映这些变更。" +
"请基于摘要和当前状态继续,避免重复已完成的工作:\n\n";
/**
* Marker prefix used by the first-user anchor. Lets compaction skip
* previously-injected anchors when looking for the "real" first user
* message in a subsequent round.
*
*
Package-private so unit tests can assert on the marker.
*/
static final String ANCHOR_PREFIX = "[Original goal]\n";
// ==================== 序列化截断参数 ====================
private static final int CONTENT_MAX = 6000;
private static final int CONTENT_HEAD = 4000;
private static final int CONTENT_TAIL = 1500;
/**
* Minimum body size at which the duplicate-output placeholder is preferred
* over keeping the verbatim copy. Below this size the placeholder text
* (~80 chars) is comparable to the body itself, so deduplication only
* complicates the prompt without saving meaningful tokens. Above this
* size the dedup placeholder is a real win.
*/
private static final int DEDUP_MIN_CHARS = 500;
/**
* Tool names whose results must never be compacted into a one-line
* summary.
*
*
Sub-agent delegations are irreplaceable: the child runs an
* independent LLM session that the parent cannot reproduce, so dropping
* earlier batches forces the parent to re-dispatch the same children to
* recover what was lost.
*
*
{@code load_skill} returns the SKILL.md content at load time —
* a snapshot of the skill's constraints, flow, and script entrypoints.
* The skill author may update SKILL.md between the original load and a
* hypothetical re-load, so re-invoking {@code load_skill} does NOT
* guarantee recovering the same instructions the agent started with.
* Dropping the original also forces the agent to either re-load (token
* expensive for 50KB+ skills) or operate without constraints — the
* root cause of the "skill constraint forgetting" bug. Pinning the
* original ToolResponseMessage keeps the agent's understanding of the
* task's rules stable across context-window trims.
*
*
Every other tool (read_file, shell, search, memory) can be
* re-invoked cheaply if the parent decides it needs the data again.
*/
private static final java.util.Set PRUNE_EXEMPT_TOOLS = java.util.Set.of(
"delegateToAgent",
"delegateParallel",
"load_skill"
);
// ==================== 冷却机制 ====================
/** 摘要失败后的冷却时间(毫秒):10 分钟 */
private static final long SUMMARY_COOLDOWN_MS = 600_000;
// ==================== 依赖 ====================
private final ConversationWindowProperties properties;
private final MemoryManager memoryManager;
private final ConversationService conversationService;
/**
* Optional — adaptive compaction trigger for small context windows.
* Setter-injected so the many direct test constructions keep the plain
* configured ratio (null → previous behavior).
*/
private PrefixBudgetPlanner prefixBudgetPlanner;
@Autowired(required = false)
public void setPrefixBudgetPlanner(PrefixBudgetPlanner prefixBudgetPlanner) {
this.prefixBudgetPlanner = prefixBudgetPlanner;
}
/**
* Optional spill store, injected via setter so unit tests and the two
* existing 3-arg constructor callers in tests stay source-compatible.
* When {@code null}, prune falls back to "keep originals verbatim" — no
* lossy summary rewrite is ever applied. Spring autowires this when
* {@link ToolResultStorage} is on the context.
*/
private ToolResultStorage toolResultStorage;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setToolResultStorage(ToolResultStorage toolResultStorage) {
this.toolResultStorage = toolResultStorage;
}
/**
* Optional stream tracker for broadcasting {@code compact_status}
* SSE events. Wired via setter so unit tests can leave it {@code null}
* without dragging in the channel layer. When present, every
* compaction emits start/skipped/summarize/done events so the
* frontend can render a boundary card and a status line in real
* time.
*/
private vip.mate.channel.web.ChatStreamTracker streamTracker;
@org.springframework.beans.factory.annotation.Autowired(required = false)
public void setStreamTracker(vip.mate.channel.web.ChatStreamTracker streamTracker) {
this.streamTracker = streamTracker;
}
// ==================== 状态 ====================
/** 摘要缓存:key = "conversationId:oldMessageCount" */
private final ConcurrentHashMap summaryCache = new ConcurrentHashMap<>();
private static final long CACHE_TTL_MS = 30 * 60 * 1000L;
/** 迭代摘要:上一次压缩生成的摘要文本(per conversation) */
private final ConcurrentHashMap previousSummaries = new ConcurrentHashMap<>();
/** 每个会话的压缩次数 */
private final ConcurrentHashMap compressionCounts = new ConcurrentHashMap<>();
/** 每个会话的摘要冷却截止时间 */
private final ConcurrentHashMap summaryCooldownUntil = new ConcurrentHashMap<>();
/** Per-conversation last-PTL-forced-compaction timestamp. The structured
* PTL retry path is guarded by {@link #PTL_FORCE_LLM_COOLDOWN_MS} — a
* second PTL hit within the cooldown falls straight back to tail-only
* trimming. Without this, a model that keeps regenerating tool-call
* loops can drive a chain of summary-LLM calls and lock the
* conversation in a compaction storm. */
private final ConcurrentHashMap ptlForceCompactAt = new ConcurrentHashMap<>();
/**
* Default max input tokens for the configured model window. Surfaced for
* the per-loop budgeter so the L1 (multi-turn compaction) and L2
* (per-iteration trim) layers stay calibrated to the same number.
*/
public int getDefaultMaxInputTokens() {
return properties != null ? properties.getDefaultMaxInputTokens() : 0;
}
/** Cooldown window after a structured PTL compaction during which a
* follow-up PTL is downgraded to tail-only. Picked so a single ReAct
* loop that retries within seconds can't burn another summary LLM
* call, while still letting the next real conversation turn (minutes
* later) get a fresh structured pass. */
private static final long PTL_FORCE_LLM_COOLDOWN_MS = 60_000L;
// ==================== 主入口 ====================
/**
* 将会话历史裁剪到上下文窗口内。
*
* @param messages 已转换的 Spring AI 消息列表(不含当前用户消息)
* @param systemPrompt 系统提示词文本
* @param currentUserMessage 当前用户输入(纳入窗口预算计算,但不拼入返回结果)
* @param maxInputTokens 模型最大输入 token(0 或 null 使用全局默认)
* @param chatModel 用于生成摘要的 ChatModel
* @param conversationId 会话 ID(用于缓存和迭代摘要)
* @param agentId Agent ID(用于 MemoryProvider 钩子)
* @return 裁剪后的消息列表
*/
public List fitToWindow(List messages, String systemPrompt,
String currentUserMessage,
Integer maxInputTokens, ChatModel chatModel,
String conversationId, Long agentId) {
return fitToWindow(messages, systemPrompt, currentUserMessage,
maxInputTokens, chatModel, conversationId, agentId, null, null);
}
/**
* Same as the 7-arg overload but additionally accounts for the tool
* definitions sent on every LLM call. Without {@code toolCallbacks},
* the budget calculation underestimates the actual request size by the
* full size of the tools schema (often several thousand tokens for
* agents bound to multiple MCP servers), making compression fire too
* late and producing HTTP 400 once the request hits the model.
*/
public List fitToWindow(List messages, String systemPrompt,
String currentUserMessage,
Integer maxInputTokens, ChatModel chatModel,
String conversationId, Long agentId,
java.util.Collection toolCallbacks) {
return fitToWindow(messages, systemPrompt, currentUserMessage,
maxInputTokens, chatModel, conversationId, agentId, toolCallbacks, null);
}
/**
* Most comprehensive overload — adds {@code workspaceBasePath} so the
* pre-pass that prunes old tool results can route oversized bodies to
* the agent's workspace spill directory via {@link ToolResultStorage}.
*
* When {@code workspaceBasePath} is {@code null}, spill files land in
* the configured base dir, or the JVM tmpdir as last resort (see
* {@link ToolResultStorage#resolveBaseDir(String)}). Workspace-aware
* callers should always pass the path so historical spill files stay
* grouped with the workspace that produced them.
*/
public List fitToWindow(List messages, String systemPrompt,
String currentUserMessage,
Integer maxInputTokens, ChatModel chatModel,
String conversationId, Long agentId,
java.util.Collection toolCallbacks,
String workspaceBasePath) {
if (messages == null || messages.isEmpty()) {
// Still surface an occupancy snapshot for the very first turn so
// the chat panel shows context usage from message one on.
int firstTurnMax = (maxInputTokens != null && maxInputTokens > 0)
? maxInputTokens : properties.getDefaultMaxInputTokens();
broadcastContextUsage(conversationId, firstTurnMax,
TokenEstimator.estimateTokens(systemPrompt),
TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD,
0,
TokenEstimator.estimateToolsTokens(toolCallbacks),
false);
return messages;
}
long spillsAtEntry = (toolResultStorage != null) ? toolResultStorage.getSpillCount() : 0L;
messages = pruneOldToolResultsForModelInput(messages, conversationId, workspaceBasePath);
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
? maxInputTokens : properties.getDefaultMaxInputTokens();
// Small windows compact later (higher trigger ratio): summarizing at
// 75% of an 8k window throws away room it cannot afford to lose.
double triggerRatio = prefixBudgetPlanner != null
? prefixBudgetPlanner.compactTriggerRatioFor(effectiveMax, properties.getCompactTriggerRatio())
: properties.getCompactTriggerRatio();
int triggerThreshold = (int) (effectiveMax * triggerRatio);
int systemTokens = TokenEstimator.estimateTokens(systemPrompt);
int currentMsgTokens = TokenEstimator.estimateTokens(currentUserMessage) + TokenEstimator.PER_MESSAGE_OVERHEAD;
int historyTokens = TokenEstimator.estimateTokens(messages);
int toolsTokens = TokenEstimator.estimateToolsTokens(toolCallbacks);
int totalTokens = systemTokens + currentMsgTokens + historyTokens + toolsTokens;
broadcastContextUsage(conversationId, effectiveMax, systemTokens, currentMsgTokens,
historyTokens, toolsTokens, totalTokens > triggerThreshold);
if (totalTokens <= triggerThreshold) {
return messages;
}
log.info("[ConversationWindow] 超阈值: {} tokens (system={}, current={}, history={}, tools={}) > {} 触发阈值 (max={}), conv={}",
totalTokens, systemTokens, currentMsgTokens, historyTokens, toolsTokens,
triggerThreshold, effectiveMax, conversationId);
evictExpiredEntries();
// 可用于历史的 token 预算 = max - system - currentMsg - tools - 安全余量
int reservedTokens = systemTokens + currentMsgTokens + toolsTokens + (int) (effectiveMax * 0.05);
// 预留 reserve 硬封顶到 effectiveMax 的 50%。
// 小上下文模型(Ollama 16K、本地 8K)下,systemTokens + currentMsgTokens 很容易
// 接近或超过 effectiveMax,不封顶会让 historyBudget 变负数导致死循环压缩
// (压缩目标比压缩前还大 → 压缩后又触发压缩)。
int reservedCap = Math.max(1024, effectiveMax / 2);
if (reservedTokens > reservedCap) {
log.warn("[ConversationWindow] 预留 token {} 超过上下文窗口 50% {},封顶至 {}",
reservedTokens, effectiveMax, reservedCap);
reservedTokens = reservedCap;
}
int historyBudget = effectiveMax - reservedTokens;
// 尾部保护 token 预算:阈值的 20%
int tailTokenBudget = (int) (triggerThreshold * 0.20);
List compacted = compactMessages(messages, historyBudget, tailTokenBudget, chatModel,
conversationId, agentId, totalTokens, spillsAtEntry, "token_threshold",
workspaceBasePath);
// Re-broadcast so the occupancy gauge falls immediately after
// compaction instead of waiting for the next window-fit pass.
broadcastContextUsage(conversationId, effectiveMax, systemTokens, currentMsgTokens,
TokenEstimator.estimateTokens(compacted), toolsTokens, false);
return compacted;
}
/**
* 向后兼容:不传 agentId 的旧签名(agentId = null,不触发 Memory 钩子)
*/
public List fitToWindow(List messages, String systemPrompt,
String currentUserMessage,
Integer maxInputTokens, ChatModel chatModel,
String conversationId) {
return fitToWindow(messages, systemPrompt, currentUserMessage,
maxInputTokens, chatModel, conversationId, null);
}
// ==================== 核心压缩逻辑 ====================
/**
* Broadcast a {@code context_usage} snapshot: how much of the effective
* input window the next LLM call will occupy, split by source (system
* prompt / tool schemas / history / current input). Values come from
* {@link TokenEstimator}, so they are heuristic estimates for a gauge,
* not billing-grade counts — the UI labels them as such. Fired once per
* window-fit pass (i.e. per reasoning step) and again after compaction
* completes so the gauge falls back. Silent no-op when no tracker is
* wired or the window size is unknown.
*/
private void broadcastContextUsage(String conversationId, int windowTokens,
int systemTokens, int currentTokens,
int historyTokens, int toolsTokens,
boolean willCompact) {
if (streamTracker == null || conversationId == null || conversationId.isEmpty()
|| windowTokens <= 0) {
return;
}
try {
int usedTokens = systemTokens + currentTokens + historyTokens + toolsTokens;
Map payload = new java.util.LinkedHashMap<>();
payload.put("windowTokens", windowTokens);
payload.put("usedTokens", usedTokens);
payload.put("systemTokens", systemTokens);
payload.put("currentTokens", currentTokens);
payload.put("historyTokens", historyTokens);
payload.put("toolsTokens", toolsTokens);
payload.put("ratio", windowTokens > 0 ? (double) usedTokens / windowTokens : 0d);
payload.put("willCompact", willCompact);
payload.put("timestamp", System.currentTimeMillis());
streamTracker.broadcastObject(conversationId, "context_usage", payload);
} catch (Exception e) {
log.debug("[ConversationWindow] broadcast context_usage failed: {}", e.getMessage());
}
}
/** Broadcast a single compact_status event; silent no-op when no tracker is wired. */
private void broadcastCompactStatus(String conversationId, String status, Map extra) {
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
return;
}
try {
Map payload = new java.util.LinkedHashMap<>();
payload.put("status", status);
payload.put("timestamp", System.currentTimeMillis());
if (extra != null) payload.putAll(extra);
streamTracker.broadcastObject(conversationId, "compact_status", payload);
} catch (Exception e) {
log.debug("[ConversationWindow] broadcast compact_status failed: {}", e.getMessage());
}
}
private List compactMessages(List messages, int historyBudget,
int tailTokenBudget, ChatModel chatModel,
String conversationId, Long agentId,
int preTokens, long spillsAtEntry,
String trigger, String workspaceBasePath) {
broadcastCompactStatus(conversationId, "start", Map.of(
"preTokens", preTokens,
"messagesIn", messages.size(),
"trigger", trigger
));
// 动态计算尾部保护边界(替代固定 preserveRecentPairs)
int headEnd = 0; // 头部保护:暂不保护(system prompt 已在外部计算)
int tailStart = findTailBoundary(messages, headEnd, tailTokenBudget);
if (tailStart <= headEnd) {
log.debug("[ConversationWindow] 消息数不足以拆分,跳过压缩");
broadcastCompactStatus(conversationId, "skipped",
Map.of("reason", "insufficient_messages"));
return messages;
}
// Pair safety: never split an AssistantMessage's tool_calls from its
// matching ToolResponseMessages. The cut may walk forward (i.e. the
// tail grows) until every call/response cluster lives on one side of
// the boundary. If no safe cut survives the walk, skip compaction —
// a broken pair would 400 every OpenAI-compatible provider, which is
// strictly worse than letting context cross the budget by one extra
// turn.
int pairSafeCut = enforcePairSafeBoundary(messages, headEnd, tailStart);
if (pairSafeCut <= headEnd) {
broadcastCompactStatus(conversationId, "skipped",
Map.of("reason", "pair_boundary_collapsed"));
return messages;
}
if (pairSafeCut != tailStart) {
broadcastCompactStatus(conversationId, "pair_safe", Map.of(
"movedFrom", tailStart, "movedTo", pairSafeCut));
}
tailStart = pairSafeCut;
List oldMessages = new ArrayList<>(messages.subList(headEnd, tailStart));
List recentMessages = messages.subList(tailStart, messages.size());
// ═══ Phase 1: Soft Trim — 裁剪旧工具结果 ═══
int softTrimmed = softTrimToolResults(oldMessages);
if (softTrimmed > 0) {
int afterTrimTokens = TokenEstimator.estimateTokens(oldMessages) + TokenEstimator.estimateTokens(recentMessages);
log.info("[ConversationWindow] Phase 1 Soft trim: {} tool results trimmed, tokens={}, budget={}",
softTrimmed, afterTrimTokens, historyBudget);
if (afterTrimTokens <= historyBudget) {
List result = new ArrayList<>(oldMessages);
result.addAll(recentMessages);
return result;
}
}
// ═══ Phase 2: Hard Clear — 替换所有旧工具结果为占位符 ═══
int hardCleared = hardClearToolResults(oldMessages);
if (hardCleared > 0) {
int afterClearTokens = TokenEstimator.estimateTokens(oldMessages) + TokenEstimator.estimateTokens(recentMessages);
log.info("[ConversationWindow] Phase 2 Hard clear: {} replaced, tokens={}, budget={}",
hardCleared, afterClearTokens, historyBudget);
if (afterClearTokens <= historyBudget) {
List result = new ArrayList<>(oldMessages);
result.addAll(recentMessages);
return result;
}
}
// ═══ Phase 2.5: MemoryProvider 钩子 — 压缩前提取关键信息 ═══
String memoryExtraContext = "";
if (agentId != null && memoryManager != null) {
try {
String preserved = memoryManager.onPreCompress(agentId, oldMessages);
if (preserved != null && !preserved.isBlank()) {
memoryExtraContext = preserved;
log.debug("[ConversationWindow] MemoryProvider onPreCompress contributed {} chars", preserved.length());
}
} catch (Exception e) {
log.debug("[ConversationWindow] onPreCompress hook failed: {}", e.getMessage());
}
}
// ═══ Phase 2.7: Move 4 — Lossless spill evict ═══
// Before paying the LLM-summary cost (lossy + tokens + latency),
// try to bring oldMessages under budget by spilling remaining
// oversized tool results to disk. Each spilled result becomes a
// compact spill-marker (preview + on-disk path), recoverable via
// read_file. Exempt tools and already-spilled markers are skipped.
// If this phase lands the token count under historyBudget, the
// LLM summary is skipped entirely — the eviction is lossless.
if (toolResultStorage != null && workspaceBasePath != null) {
int spilled = spillEvictToolResults(oldMessages, conversationId, workspaceBasePath);
if (spilled > 0) {
int afterSpillTokens = TokenEstimator.estimateTokens(oldMessages)
+ TokenEstimator.estimateTokens(recentMessages);
log.info("[ConversationWindow] Phase 2.7 Spill evict: {} results spilled, tokens={}, budget={}",
spilled, afterSpillTokens, historyBudget);
if (afterSpillTokens <= historyBudget) {
List result = new ArrayList<>(oldMessages);
result.addAll(recentMessages);
broadcastCompactStatus(conversationId, "done", Map.of(
"trigger", trigger,
"preTokens", preTokens,
"postTokens", afterSpillTokens,
"strategy", "lossless_spill_evict",
"resultsSpilled", spilled
));
return result;
}
}
}
// ═══ Phase 3: Pre-Prune + LLM 结构化摘要 ═══
// Pre-prune:在喂给摘要 LLM 前清理旧消息中的工具输出
List forSummary = new ArrayList<>(oldMessages);
int prePruned = prePruneForSummary(forSummary);
if (prePruned > 0) {
log.info("[ConversationWindow] Phase 3 Pre-prune: {} tool results cleared before summarization", prePruned);
}
// 计算动态摘要预算
int summaryBudget = computeSummaryBudget(forSummary);
broadcastCompactStatus(conversationId, "summarize", Map.of(
"messagesToSummarize", oldMessages.size(),
"summaryBudget", summaryBudget
));
// 检查缓存
String cacheKey = conversationId + ":" + oldMessages.size();
CachedSummary cached = summaryCache.get(cacheKey);
String summary;
boolean fromCache = false;
if (cached != null && !cached.isExpired(CACHE_TTL_MS)) {
summary = cached.summary();
fromCache = true;
log.debug("[ConversationWindow] 命中摘要缓存, conv={}", conversationId);
} else {
summary = generateSummary(forSummary, chatModel, conversationId, summaryBudget, memoryExtraContext);
if (summary != null) {
summaryCache.put(cacheKey, new CachedSummary(summary, System.currentTimeMillis()));
int count = compressionCounts.merge(conversationId, 1, Integer::sum);
log.info("[ConversationWindow] 生成结构化摘要 ({} 字符, 第 {} 次压缩), 压缩 {} 条旧消息, conv={}",
summary.length(), count, oldMessages.size(), conversationId);
}
}
// 组装结果
List result = new ArrayList<>();
boolean anchored = false;
if (summary != null && !summary.isBlank()) {
result.add(new UserMessage(SUMMARY_PREFIX + summary));
// Anchor the original user goal so a long task that paged through
// dozens of turns can still see what was originally asked. Always
// as a UserMessage — promoting historical user input to a
// SystemMessage would be a privilege-escalation risk.
Message anchor = buildFirstUserAnchor(oldMessages);
if (anchor != null) {
result.add(anchor);
anchored = true;
}
} else if (!oldMessages.isEmpty()) {
log.warn("[ConversationWindow] 摘要生成失败,降级为保留最近 4 条旧消息, conv={}", conversationId);
int fallbackKeep = Math.min(4, oldMessages.size());
result.addAll(oldMessages.subList(oldMessages.size() - fallbackKeep, oldMessages.size()));
broadcastCompactStatus(conversationId, "failed", Map.of(
"reason", "summary_generation_failed",
"fallbackKept", fallbackKeep
));
}
result.addAll(recentMessages);
// 压缩后校验
int resultTokens = TokenEstimator.estimateTokens(result);
if (resultTokens > historyBudget && result.size() > 2) {
log.warn("[ConversationWindow] 压缩后仍超预算: {} > {}, 执行二次裁剪", resultTokens, historyBudget);
result = trimToFit(result, historyBudget);
resultTokens = TokenEstimator.estimateTokens(result);
}
// Persist the boundary + announce completion only when the summary
// actually wrote a row. Failed-summary fallback already broadcast
// its own event above.
if (summary != null && !summary.isBlank() && conversationService != null && !fromCache) {
long spillsThisTurn = (toolResultStorage != null)
? Math.max(0L, toolResultStorage.getSpillCount() - spillsAtEntry)
: 0L;
Map boundaryMetadata = new java.util.LinkedHashMap<>();
boundaryMetadata.put("trigger", trigger);
boundaryMetadata.put("preTokens", preTokens);
boundaryMetadata.put("postTokens", resultTokens);
boundaryMetadata.put("messagesSummarized", oldMessages.size());
boundaryMetadata.put("tailKept", recentMessages.size());
boundaryMetadata.put("toolResultsSpilled", spillsThisTurn);
boundaryMetadata.put("anchored", anchored);
Long summaryId = null;
try {
summaryId = conversationService.saveCompressionSummaryReturningId(
conversationId, SUMMARY_PREFIX + summary, oldMessages.size(),
boundaryMetadata);
} catch (Exception e) {
log.warn("[ConversationWindow] Failed to persist compression boundary: {}", e.getMessage());
}
if (summaryId != null) {
// Mirror the DB row's metadata: the SSE consumer needs the id
// to deep-link the boundary card without having to refetch.
boundaryMetadata.put("summaryId", summaryId);
}
broadcastCompactStatus(conversationId, "done", boundaryMetadata);
} else if (summary != null && !summary.isBlank() && fromCache) {
// Cached summary path — no new DB row, but emit done so the
// frontend status bar still updates.
broadcastCompactStatus(conversationId, "done", Map.of(
"preTokens", preTokens,
"postTokens", resultTokens,
"messagesSummarized", oldMessages.size(),
"tailKept", recentMessages.size(),
"fromCache", true
));
}
return result;
}
// ==================== 动态 Token 预算 ====================
/**
* 基于 token 预算动态计算尾部保护边界(替代固定 preserveRecentPairs)。
* 从消息列表末尾向前累加 token,直到耗尽预算或达到最小消息数。
*/
private int findTailBoundary(List messages, int headEnd, int tailTokenBudget) {
int n = messages.size();
if (n <= headEnd + 1) return headEnd;
int minTail = Math.min(properties.getProtectLastMinMessages(), n - headEnd - 1);
// 兼容旧配置:如果 protectLastMinMessages 未设置但 preserveRecentPairs 有值
int pairsBased = properties.getPreserveRecentPairs() * 2;
if (pairsBased > minTail) {
minTail = Math.min(pairsBased, n - headEnd - 1);
}
int softCeiling = (int) (tailTokenBudget * 1.5);
int accumulated = 0;
int cutIdx = n;
for (int i = n - 1; i >= headEnd; i--) {
int msgTokens = TokenEstimator.estimateTokens(messages.get(i));
if (accumulated + msgTokens > softCeiling && (n - i) >= minTail) {
break;
}
accumulated += msgTokens;
cutIdx = i;
}
// 确保至少保留 minTail 条
int fallbackCut = n - minTail;
if (cutIdx > fallbackCut) {
cutIdx = fallbackCut;
}
return Math.max(cutIdx, headEnd + 1);
}
/**
* Adjust the candidate boundary so an {@link AssistantMessage}'s
* {@code toolCalls} are never separated from their matching
* {@link ToolResponseMessage}s.
*
* Walks forward, collecting every {@code tool_call_id}'s assistant
* index and the indices of its matching responses. Whenever an
* assistant in the prefix has at least one response in the tail, the
* cut moves backward to that assistant — pulling the whole cluster
* into the tail. The walk repeats until convergence because moving
* the cut can expose pairs that were previously fully in the tail.
*
*
The method preserves pair integrity above any other concern. If
* the cut collapses all the way to {@code headEnd}, callers must
* interpret the return as "skip compaction this turn" — splitting a
* pair would produce HTTP 400 on every OpenAI-compatible provider,
* which is a worse failure mode than letting context grow by one turn.
*
*
An orphan {@code ToolResponseMessage} (id matching no
* assistant in scope) does not trigger movement; the upstream code
* paths should never produce one, and logging at WARN gives us a
* breadcrumb if they ever do.
*
* @return adjusted cut index, or {@code headEnd} when no pair-safe
* cut larger than {@code headEnd} can be produced.
*/
// Package-private so unit tests in the same package can drive it directly
// without standing up a ChatModel + the rest of the compactMessages pipeline.
int enforcePairSafeBoundary(List messages, int headEnd, int tailStart) {
if (tailStart <= headEnd || tailStart >= messages.size()) {
return tailStart;
}
int cut = tailStart;
int safety = messages.size() + 1; // hard guard against pathological loops
while (safety-- > 0) {
// Map: tool_call_id -> earliest assistant index that issued it.
java.util.Map assistantIdxById = new java.util.HashMap<>();
// Map: tool_call_id -> max response index closing it.
java.util.Map latestResponseIdxById = new java.util.HashMap<>();
for (int i = headEnd; i < messages.size(); i++) {
Message m = messages.get(i);
if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
for (AssistantMessage.ToolCall tc : am.getToolCalls()) {
String tid = tc.id();
if (tid == null || tid.isEmpty()) continue;
// Keep the first occurrence so the cut "snaps" to the
// earliest assistant for any duplicated ids; the same
// id should never repeat anyway.
assistantIdxById.putIfAbsent(tid, i);
}
} else if (m instanceof ToolResponseMessage trm) {
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
String tid = r.id();
if (tid == null || tid.isEmpty()) continue;
latestResponseIdxById.merge(tid, i, Math::max);
}
}
}
// Find the earliest in-prefix assistant whose pair is split.
int earliestSplitAssistant = Integer.MAX_VALUE;
for (var e : assistantIdxById.entrySet()) {
String id = e.getKey();
int aIdx = e.getValue();
Integer rIdx = latestResponseIdxById.get(id);
if (rIdx == null) {
// Assistant issued a call but no response — orphan call,
// would already break the provider. Not a pair-split, ignore.
continue;
}
if (aIdx < cut && rIdx >= cut && aIdx < earliestSplitAssistant) {
earliestSplitAssistant = aIdx;
}
if (aIdx >= cut && rIdx < cut) {
log.warn("[ConversationWindow] Orphan tool response in prefix without preceding assistant in tail (id={}); leaving boundary alone",
id);
}
}
if (earliestSplitAssistant == Integer.MAX_VALUE) {
break; // converged: no splits remain
}
cut = earliestSplitAssistant;
}
if (cut <= headEnd) {
log.info("[ConversationWindow] Pair-safe boundary collapsed to {} for conv: skipping compaction this turn to avoid splitting a tool_call ↔ tool_response pair",
headEnd);
return headEnd;
}
int prefixSize = cut - headEnd;
int minPrefix = Math.max(0, properties.getPairSafeMinPrefixToCompact());
if (prefixSize < minPrefix) {
log.info("[ConversationWindow] Pair-safe boundary left {} prefix message(s) (< minPrefix={}); skipping compaction",
prefixSize, minPrefix);
return headEnd;
}
if (cut != tailStart) {
log.info("[ConversationWindow] Pair-safe boundary moved {} -> {} to keep tool_call ↔ tool_response pairs intact",
tailStart, cut);
}
return cut;
}
/**
* Build an anchor message replaying the first real user input
* found in the compressed prefix. "Real" here excludes prior
* compaction artifacts ({@link #SUMMARY_PREFIX} / {@link #ANCHOR_PREFIX}
* messages from earlier rounds), because anchoring the previous
* summary defeats the purpose — the model would just see "[Original
* goal] [上下文压缩] …" pointing at compressor output, not at the user's
* actual request.
*
* Sizing rules:
*
* - ≤ {@code firstUserAnchorMaxTokens}: keep the original text verbatim.
* - ≤ 3× the budget: head+tail truncate to the budget so most of
* the prompt-cache benefit survives.
* - > 3× the budget: degrade to a 200-char pointer line so we
* don't blow prompt cache or the summary budget on a single
* message that was probably a pasted spec the model can re-read
* from the workspace anyway.
*
*
* Always returns a {@link UserMessage}. {@code null} when anchoring
* is disabled, no real first user exists in the prefix, or the body is
* blank.
*
*
Package-private for direct unit testing — the surrounding
* {@link #compactMessages} path needs a ChatModel and the whole
* structured-summary pipeline, which the anchor logic does not.
*/
Message buildFirstUserAnchor(List oldMessages) {
if (!properties.isFirstUserAnchorEnabled()) {
return null;
}
UserMessage firstUser = null;
for (Message m : oldMessages) {
if (!(m instanceof UserMessage um)) continue;
String text = um.getText();
if (text == null) continue;
// Skip synthetic prior-round artifacts.
if (text.startsWith(SUMMARY_PREFIX) || text.startsWith(ANCHOR_PREFIX)) {
continue;
}
firstUser = um;
break;
}
if (firstUser == null) return null;
String text = firstUser.getText();
if (text == null || text.isBlank()) return null;
int maxAnchorTokens = Math.max(40, properties.getFirstUserAnchorMaxTokens());
int textTokens = TokenEstimator.estimateTokens(text);
if (textTokens <= maxAnchorTokens) {
return new UserMessage(ANCHOR_PREFIX + text);
}
// > 3× budget: cheap pointer line so we don't pay token tax for a
// gigantic pasted spec. The model still knows the original goal
// existed without seeing the full body.
if (textTokens > maxAnchorTokens * 3L) {
int pointerChars = Math.min(text.length(), 200);
String pointer = text.substring(0, pointerChars).stripTrailing()
+ (text.length() > pointerChars ? "..." : "");
log.info("[ConversationWindow] First-user anchor downgraded to pointer ({} tokens > 3× budget {})",
textTokens, maxAnchorTokens);
return new UserMessage(ANCHOR_PREFIX + pointer);
}
// Within 3× — head+tail truncate to the budget. The 2 chars/token
// ratio is a deliberate over-estimate so the anchor never inflates
// past the configured budget on ASCII-heavy input.
int budgetChars = Math.max(160, maxAnchorTokens * 2);
if (budgetChars >= text.length()) {
return new UserMessage(ANCHOR_PREFIX + text);
}
int headLen = (int) (budgetChars * 0.6);
int tailLen = Math.max(40, budgetChars - headLen - 40);
if (headLen + tailLen >= text.length()) {
return new UserMessage(ANCHOR_PREFIX + text);
}
String truncated = text.substring(0, headLen)
+ "\n...[" + (text.length() - headLen - tailLen) + " chars truncated]...\n"
+ text.substring(text.length() - tailLen);
log.info("[ConversationWindow] First-user anchor head+tail truncated ({} -> ~{} chars)",
text.length(), truncated.length());
return new UserMessage(ANCHOR_PREFIX + truncated);
}
/**
* 计算摘要字数预算:被压缩内容 token 的 20%,不低于 500、不超过 3000。
*/
private int computeSummaryBudget(List turnsToSummarize) {
int contentTokens = TokenEstimator.estimateTokens(turnsToSummarize);
int budget = (int) (contentTokens * properties.getSummaryBudgetRatio());
return Math.max(properties.getSummaryBudgetFloor(),
Math.min(budget, properties.getSummaryBudgetCeiling()));
}
// ==================== 工具结果处理 ====================
/**
* Backwards-compatible overload — older tool results that are oversized
* stay verbatim because no {@link ToolResultStorage} target is in
* scope. New call sites should use the 3-arg overload with explicit
* {@code conversationId} and {@code workspaceBasePath} so oversized
* bodies can be spilled to disk and recovered via {@code read_file}.
*/
public List pruneOldToolResultsForModelInput(List messages) {
return pruneOldToolResultsForModelInput(messages, null, null);
}
/**
* Walk the messages newest-to-oldest, keeping the latest tool response
* verbatim and applying space-saving rewrites to older ones:
*
*
* - Bodies already starting with {@link ToolResultStorage#SPILL_MARKER_PREFIX}
* were spilled at tool-execution time — pass through untouched.
* - If a body matches an identical body already seen in a newer turn,
* replace it with a short "duplicate tool output omitted" placeholder
* (only above {@link #DEDUP_MIN_CHARS} so we don't bloat tiny acks).
* - Otherwise, when a {@link ToolResultStorage} is wired and a
* conversation id is available, try
* {@link ToolResultStorage#persistIfOversized} to spill the raw
* bytes to disk and replace the inline body with a preview + path
* so the model can read_file the original on demand.
* - If none of the above apply, leave the body verbatim. Bodies
* under the spill threshold or running without a storage hook are
* preserved exactly — the lossy "summarized for model context"
* single-liner that used to fire here destroyed enough context
* on long tasks to be the wrong default.
*
*
* The {@link #PRUNE_EXEMPT_TOOLS} set still bypasses everything:
* sub-agent delegations are not replayable, so their full transcript
* stays in context.
*
* @param messages full conversation in chronological order
* @param conversationId used to scope spill files; {@code null} disables spill
* @param workspaceBasePath used to locate the spill directory; {@code null}
* falls back through the storage's resolveBaseDir chain
*/
public List pruneOldToolResultsForModelInput(List messages,
String conversationId,
String workspaceBasePath) {
if (messages == null || messages.isEmpty()) {
return messages;
}
int latestToolResponseIndex = -1;
for (int i = messages.size() - 1; i >= 0; i--) {
if (messages.get(i) instanceof ToolResponseMessage) {
latestToolResponseIndex = i;
break;
}
}
if (latestToolResponseIndex <= 0) {
return messages;
}
boolean canSpill = toolResultStorage != null
&& conversationId != null && !conversationId.isEmpty();
List pruned = new ArrayList<>(messages);
java.util.Set seenLargeOutputs = new java.util.HashSet<>();
int changed = 0;
int spilled = 0;
for (int i = pruned.size() - 1; i >= 0; i--) {
if (!(pruned.get(i) instanceof ToolResponseMessage trm)) {
continue;
}
boolean keepFull = i == latestToolResponseIndex;
List newResponses = new ArrayList<>();
boolean messageChanged = false;
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
String data = r.responseData();
String name = r.name();
boolean exempt = name != null && PRUNE_EXEMPT_TOOLS.contains(name);
boolean alreadySpilled = data != null
&& data.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX);
// Pass through: the latest response, exempt tools, empty bodies,
// already-spilled previews — none should be rewritten.
if (keepFull || exempt || data == null || data.isEmpty() || alreadySpilled) {
newResponses.add(r);
if (data != null && data.length() > DEDUP_MIN_CHARS) {
seenLargeOutputs.add(data);
}
continue;
}
// Dedup: identical body seen in a later turn already.
if (data.length() > DEDUP_MIN_CHARS && seenLargeOutputs.contains(data)) {
String replacement = "[" + name
+ "] duplicate tool output omitted; same content appeared later.";
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), name, replacement));
messageChanged = true;
continue;
}
// Spill on demand: route oversized bodies to disk so the model
// can read_file them rather than losing them to a lossy summary.
if (canSpill) {
String candidate = toolResultStorage.persistIfOversized(
data, name, r.id(), conversationId, workspaceBasePath);
if (candidate != null
&& candidate.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX)) {
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), name, candidate));
seenLargeOutputs.add(data);
messageChanged = true;
spilled++;
continue;
}
// returned unchanged: under threshold, excluded tool, or write failed.
// Fall through to "keep verbatim".
}
// Default: keep the body verbatim. Better to send a few extra
// tokens than to silently destroy data the model might need.
newResponses.add(r);
if (data.length() > DEDUP_MIN_CHARS) {
seenLargeOutputs.add(data);
}
}
if (messageChanged) {
pruned.set(i, ToolResponseMessage.builder().responses(newResponses).build());
changed++;
}
}
if (changed > 0) {
log.info("[ConversationWindow] Pruned {} older tool response message(s) ({} spilled to disk) before model request",
changed, spilled);
}
return changed > 0 ? pruned : messages;
}
/**
* Spill-marker responses already point at an on-disk full copy via
* {@code path=...} in their body. Trimming, replacing, or pre-pruning
* them would destroy the very pointer the model needs to recover the
* original output with {@code read_file} — which is the whole reason
* we spilled in the first place. All three compaction phases consult
* this guard before touching a response.
*/
static boolean isSpillMarker(ToolResponseMessage.ToolResponse r) {
return r != null
&& r.responseData() != null
&& r.responseData().startsWith(ToolResultStorage.SPILL_MARKER_PREFIX);
}
/**
* Move 4 — exempt tools ({@link #PRUNE_EXEMPT_TOOLS}) must bypass every
* compaction phase, not just the size-based and age-based passes. Their
* outputs are not replayable (sub-agent delegations run independent LLM
* sessions) or not safely recoverable (load_skill returns the SKILL.md
* snapshot at load time, which the author may have edited since). Trimming
* or clearing them in Phase 1/2/3 silently drops the skill's constraints
* and the sub-agent's transcript — the root cause of the
* "compression causes attention failure" symptom.
*/
static boolean isExemptTool(ToolResponseMessage.ToolResponse r) {
return r != null
&& r.name() != null
&& PRUNE_EXEMPT_TOOLS.contains(r.name());
}
/**
* Age-based compaction. Replace bodies of all tool responses older than
* the {@code keepRecentN} most recent with a one-line placeholder, while
* preserving the toolCallId and tool name so the assistant/tool pairing
* remains valid and the model still sees "I called X earlier" in history.
*
* Complementary to {@link #pruneOldToolResultsForModelInput}: that pass
* targets oversized or duplicate bodies regardless of age (and may spill
* to disk); this one targets aged bodies regardless of size. Both can run
* in any order — the intersection collapses to the same placeholder.
*
*
Spill-marker bodies retain their on-disk {@code path=} pointer
* inside the placeholder so a later {@code read_file} can still recover
* the original output. {@link #PRUNE_EXEMPT_TOOLS} (sub-agent delegations)
* bypass the pass entirely — their transcripts are not replayable.
*
* @param messages full conversation in chronological order
* @param keepRecentN number of newest {@link ToolResponseMessage}s kept
* verbatim; older ones are compacted. Negative or zero
* disables the pass.
*/
public List compactAgedToolResponses(List messages, int keepRecentN) {
if (messages == null || messages.isEmpty() || keepRecentN <= 0) {
return messages;
}
List out = new ArrayList<>(messages);
int seen = 0;
int compacted = 0;
boolean anyChange = false;
for (int i = out.size() - 1; i >= 0; i--) {
if (!(out.get(i) instanceof ToolResponseMessage trm)) {
continue;
}
if (seen < keepRecentN) {
seen++;
continue;
}
seen++;
List newResponses =
new ArrayList<>(trm.getResponses().size());
boolean messageChanged = false;
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
String body = r.responseData();
String name = r.name();
boolean exempt = name != null && PRUNE_EXEMPT_TOOLS.contains(name);
if (exempt || body == null || body.isEmpty()) {
newResponses.add(r);
continue;
}
String placeholder = buildAgedPlaceholder(name, body);
if (placeholder.length() < body.length()) {
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), name, placeholder));
messageChanged = true;
compacted++;
} else {
// Body is already shorter than the placeholder would be —
// collapsing it would only add tokens. Keep verbatim.
newResponses.add(r);
}
}
if (messageChanged) {
out.set(i, ToolResponseMessage.builder().responses(newResponses).build());
anyChange = true;
}
}
if (compacted > 0) {
log.info("[ConversationWindow] Aged-compacted {} tool response entries (keepRecent={}) before model request",
compacted, keepRecentN);
}
return anyChange ? out : messages;
}
/**
* Build the one-line "old tool output cleared" body. When the original
* was a spill marker, extract its {@code path=} hint so the model can
* still recover the full output via {@code read_file} on demand.
*/
static String buildAgedPlaceholder(String toolName, String body) {
String safeName = (toolName == null || toolName.isBlank()) ? "tool" : toolName;
if (body != null && body.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX)) {
int idx = body.indexOf(" path=");
if (idx >= 0) {
int end = body.indexOf('\n', idx);
String path = (end > 0 ? body.substring(idx + 6, end) : body.substring(idx + 6)).trim();
if (!path.isEmpty()) {
return "[Old tool output cleared — '" + safeName
+ "' result was spilled to " + path
+ "; use read_file on that path if you still need it.]";
}
}
}
return "[Old tool output cleared — '" + safeName
+ "' can be called again if its result is needed.]";
}
/**
* One-line informative summary for a cleared tool result: tool name,
* original size, and the first line as a gist. Far more useful to the
* model than a bare "removed" marker — it can decide whether re-running
* the tool is worth it without guessing what the output was.
*/
static String buildInformativeCleared(String toolName, String body) {
String safeName = (toolName == null || toolName.isBlank()) ? "tool" : toolName;
int length = body == null ? 0 : body.length();
String gist = "";
if (body != null) {
for (String line : body.split("\n", 8)) {
String candidate = line.strip();
if (!candidate.isEmpty()) {
gist = candidate.length() > 80 ? candidate.substring(0, 80) + "…" : candidate;
break;
}
}
}
StringBuilder sb = new StringBuilder("[").append(safeName)
.append(" → ").append(length).append(" chars, cleared to save context");
if (!gist.isEmpty()) {
sb.append("; began: \"").append(gist).append('"');
}
return sb.append("; call the tool again if the result is still needed]").toString();
}
/**
* Phase 1 - Soft trim:对工具结果做 head+tail 裁剪(保留首尾各 200 字符)。
* Spill-marker responses are left untouched so their on-disk pointer
* survives intact across compaction.
*/
int softTrimToolResults(List messages) {
int trimmed = 0;
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof ToolResponseMessage trm) {
List newResponses = new ArrayList<>();
boolean changed = false;
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
if (isSpillMarker(r)) {
// Pointer + preview already; trimming would lose the path.
newResponses.add(r);
continue;
}
if (isExemptTool(r)) {
// Move 4: load_skill / delegateToAgent outputs are not
// safely recoverable — pass through verbatim.
newResponses.add(r);
continue;
}
String data = r.responseData();
if (data != null && data.length() > 500) {
String marker = "\n...[trimmed " + data.length() + " chars; "
+ StructuredTruncator.FIDELITY_NOTE + "]...\n";
newResponses.add(new ToolResponseMessage.ToolResponse(
r.id(), r.name(), StructuredTruncator.truncate(data, 200, 200, marker)));
changed = true;
} else {
newResponses.add(r);
}
}
if (changed) {
messages.set(i, ToolResponseMessage.builder().responses(newResponses).build());
trimmed++;
}
}
}
return trimmed;
}
/**
* Phase 2 - Hard clear:将所有旧工具结果替换为占位符。
* Spill-marker responses are left untouched so the on-disk pointer
* survives — a placeholder here would force the model to abandon a
* tool output it could otherwise recover via {@code read_file}.
*/
int hardClearToolResults(List messages) {
int cleared = 0;
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof ToolResponseMessage trm) {
boolean changed = false;
List replaced = new ArrayList<>(trm.getResponses().size());
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
if (isSpillMarker(r)) {
replaced.add(r);
continue;
}
if (isExemptTool(r)) {
// Move 4: exempt tools survive Phase 2 unchanged.
replaced.add(r);
continue;
}
replaced.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(),
buildInformativeCleared(r.name(), r.responseData())));
changed = true;
}
if (changed) {
messages.set(i, ToolResponseMessage.builder().responses(replaced).build());
cleared++;
}
}
}
return cleared;
}
/**
* Phase 3 Pre-prune:在 LLM 摘要前,将工具输出替换为占位符(减少摘要输入 token)。
* Spill-marker responses are left untouched so the summary input
* still has the on-disk path the model might cite back in its summary.
*/
int prePruneForSummary(List messages) {
int pruned = 0;
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof ToolResponseMessage trm) {
// Move 4: skip the entire ToolResponseMessage if every
// response is either a spill marker or an exempt tool —
// there's nothing to prune.
boolean hasSubstantial = trm.getResponses().stream()
.anyMatch(r -> !isSpillMarker(r)
&& !isExemptTool(r)
&& r.responseData() != null
&& r.responseData().length() > 200);
if (hasSubstantial) {
List placeholders = new ArrayList<>(trm.getResponses().size());
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
if (isSpillMarker(r)) {
placeholders.add(r);
continue;
}
if (isExemptTool(r)) {
// Move 4: exempt tools survive Phase 3 pre-prune.
placeholders.add(r);
continue;
}
placeholders.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(),
"[旧工具输出已清理以节省上下文空间]"));
}
messages.set(i, ToolResponseMessage.builder().responses(placeholders).build());
pruned++;
}
}
}
return pruned;
}
/**
* Move 4 — Phase 2.7 lossless spill evict. Walks {@code messages} and
* spills each non-exempt, non-spilled, oversized tool result to disk via
* {@link ToolResultStorage#persistIfOversized}, replacing the body with
* a compact spill marker (preview + on-disk path). The model can recover
* the original via {@code read_file} on the path.
*
* Unlike Phase 1/2 (which trim/clear in place), this phase is
* lossless: the full output is preserved on disk. The token
* savings come from replacing a multi-KB body with a ~200-char preview.
* When the savings are enough to land under {@code historyBudget}, the
* caller skips the LLM summary entirely — avoiding its token cost,
* latency, and lossy compression.
*
*
Skips:
*
* - Exempt tools ({@link #PRUNE_EXEMPT_TOOLS}) — not safely
* recoverable.
* - Already-spilled markers — re-spilling would just overwrite the
* same file.
* - Bodies under {@code perResultThresholdChars} — too small to
* benefit from spilling (the marker preview is comparable in size).
*
*
* @return number of tool responses actually spilled to disk
*/
int spillEvictToolResults(List messages, String conversationId,
String workspaceBasePath) {
if (toolResultStorage == null || conversationId == null || conversationId.isBlank()) {
return 0;
}
int spilled = 0;
for (int i = 0; i < messages.size(); i++) {
if (!(messages.get(i) instanceof ToolResponseMessage trm)) continue;
List newResponses = new ArrayList<>();
boolean changed = false;
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
if (isSpillMarker(r) || isExemptTool(r)) {
newResponses.add(r);
continue;
}
String data = r.responseData();
if (data == null || data.isEmpty()) {
newResponses.add(r);
continue;
}
String spilledBody = toolResultStorage.persistIfOversized(
data, r.name(), r.id(), conversationId, workspaceBasePath);
if (spilledBody != data) {
// persistIfOversized replaced the body with a spill marker
newResponses.add(new ToolResponseMessage.ToolResponse(
r.id(), r.name(), spilledBody));
changed = true;
spilled++;
} else {
newResponses.add(r);
}
}
if (changed) {
messages.set(i, ToolResponseMessage.builder().responses(newResponses).build());
}
}
return spilled;
}
// ==================== LLM 摘要生成(结构化 + 迭代更新) ====================
/**
* 生成结构化摘要。支持首次压缩和迭代更新两种模式。
* 包含冷却机制:LLM 调用失败后 10 分钟内不重试。
*/
private String generateSummary(List oldMessages, ChatModel chatModel,
String conversationId, int summaryBudget,
String memoryExtraContext) {
// 冷却检查
if (isInSummaryCooldown(conversationId)) {
log.info("[ConversationWindow] 摘要在冷却中,跳过 LLM 调用, conv={}", conversationId);
return null;
}
try {
String conversationText = serializeForSummary(oldMessages);
// 如果 MemoryProvider 有额外上下文,追加到对话文本中
if (memoryExtraContext != null && !memoryExtraContext.isBlank()) {
conversationText += "\n\n[Memory Provider 补充上下文]\n" + memoryExtraContext;
}
String previousSummary = previousSummaries.get(conversationId);
String systemPrompt;
String userPrompt;
// System prompt always carries the budget directive; both branches
// must replace the placeholder. The previous code applied the
// replace only on the first-compression branch, so iterative-mode
// calls leaked the literal "{summary_budget}" string to the LLM.
systemPrompt = STRUCTURED_SUMMARY_SYSTEM
.replace("{summary_budget}", String.valueOf(summaryBudget));
if (previousSummary != null) {
// Iterative update: previous summary + new turns.
userPrompt = STRUCTURED_SUMMARY_UPDATE
.replace("{previous_summary}", previousSummary)
.replace("{conversation}", conversationText);
log.debug("[ConversationWindow] 使用迭代更新模式(第 {} 次压缩), conv={}",
compressionCounts.getOrDefault(conversationId, 0) + 1, conversationId);
} else {
userPrompt = STRUCTURED_SUMMARY_USER
.replace("{conversation}", conversationText);
log.debug("[ConversationWindow] 使用首次压缩模式, conv={}", conversationId);
}
List promptMessages = new ArrayList<>();
promptMessages.add(new SystemMessage(systemPrompt));
promptMessages.add(new UserMessage(userPrompt));
ChatOptions defaults = ChatModelDefaults.optionsFor(chatModel);
ChatOptions options = (defaults != null ? defaults.mutate() : ChatOptions.builder())
.maxTokens(properties.getSummaryMaxTokens())
.build();
ChatResponse response = chatModel.call(new Prompt(promptMessages, options));
if (response != null && response.getResult() != null
&& response.getResult().getOutput() != null) {
String finishReason = response.getResult().getMetadata() != null
? response.getResult().getMetadata().getFinishReason() : null;
if ("length".equalsIgnoreCase(finishReason)) {
// A non-empty response can still be structurally incomplete
// when the provider exhausts max tokens. Persisting it would
// poison every later iterative summary.
log.warn("[ConversationWindow] LLM 摘要因 token 上限被截断,丢弃结果, conv={}",
conversationId);
setSummaryCooldown(conversationId);
return null;
}
String summary = response.getResult().getOutput().getText();
if (summary != null && !summary.isBlank()) {
// 成功:保存摘要供下次迭代更新,清除冷却
previousSummaries.put(conversationId, summary);
clearSummaryCooldown(conversationId);
return summary;
}
}
log.warn("[ConversationWindow] LLM 摘要返回空结果, conv={}", conversationId);
setSummaryCooldown(conversationId);
return null;
} catch (Exception e) {
log.warn("[ConversationWindow] LLM 摘要生成失败(进入 {} 秒冷却): {}, conv={}",
SUMMARY_COOLDOWN_MS / 1000, e.getMessage(), conversationId);
setSummaryCooldown(conversationId);
return null;
}
}
// ==================== 消息序列化(智能截断) ====================
/**
* 将消息列表序列化为摘要 LLM 可消化的文本格式。
* 长内容做 head+tail 截断,比简单截断保留更多信息。
*/
private String serializeForSummary(List messages) {
StringBuilder sb = new StringBuilder();
for (Message msg : messages) {
String role = switch (msg) {
case UserMessage ignored -> "[USER]";
case SystemMessage ignored -> "[SYSTEM]";
case AssistantMessage ignored -> "[ASSISTANT]";
case ToolResponseMessage ignored -> "[TOOL RESULT]";
default -> "[OTHER]";
};
String text = msg.getText();
if (text != null && text.length() > CONTENT_MAX) {
String marker = "\n...[truncated " + text.length() + " chars; "
+ StructuredTruncator.FIDELITY_NOTE + "]...\n";
text = StructuredTruncator.truncate(text, CONTENT_HEAD, CONTENT_TAIL, marker);
}
sb.append(role).append(": ").append(text != null ? text : "").append("\n\n");
}
return sb.toString();
}
// ==================== 辅助方法 ====================
/**
* 二次裁剪:从前往后移除消息直到 token 预算满足。
*/
private List trimToFit(List messages, int budget) {
int startIndex = 0;
int totalTokens = TokenEstimator.estimateTokens(messages);
while (totalTokens > budget && startIndex < messages.size() - 2) {
totalTokens -= TokenEstimator.estimateTokens(messages.get(startIndex));
startIndex++;
}
if (startIndex > 0) {
log.info("[ConversationWindow] 二次裁剪移除 {} 条消息, 最终 {} tokens", startIndex, totalTokens);
return new ArrayList<>(messages.subList(startIndex, messages.size()));
}
return messages;
}
// ==================== PTL 紧急压缩 ====================
/**
* Structured PTL (Prompt Too Long) recovery — reuses the full
* {@link #compactMessages} pipeline (pair-safe boundary, soft/hard
* trim, MemoryProvider hook, LLM summary, anchor of the first user
* goal) under a forced-tight history budget so the retry actually fits.
*
* Differences vs the {@link #compactForRetry(List)} fallback:
*
* - Preserves the original user goal via anchor instead of dropping
* it with the head — long tasks lose context every PTL otherwise.
* - Pair-safe cuts, so the retry doesn't break a
* {@code AssistantMessage.tool_calls} / {@code ToolResponseMessage}
* cluster and 400 the provider a second time.
* - Runs through summary generation so semantic continuity (user
* preferences, completed steps) survives the trim.
* - Tags the persisted boundary row with
* {@code trigger=prompt_too_long} so the summary is retrievable
* via the same {@code mate_conversation_summary} schema as a
* normal token-threshold compaction.
*
*
* A 60s cooldown ({@link #PTL_FORCE_LLM_COOLDOWN_MS}) downgrades the
* second-and-subsequent PTL hit on one conversation to tail-only, so
* a model stuck in a tool-call retry loop can't drag the summary LLM
* along with it.
*
* @param messages Current history that overflowed the model window.
* @param chatModel Used for the summary generation step.
* @param conversationId Cooldown / cache key.
* @param agentId Drives the {@code MemoryProvider.onPreCompress}
* hook. Nullable — the hook is a no-op when null.
* @return Compacted history with summary + anchor + tail, or the
* {@link #compactForRetry(List)} tail-only fallback when the
* cooldown is active or the structured pass produces no
* reduction. {@code null} when the input is too small to
* compact (matches the legacy contract).
*/
public List compactForRetry(List messages,
ChatModel chatModel,
String conversationId,
Long agentId) {
if (messages == null || messages.size() <= 2) {
return null;
}
// Sweep the cooldown map on every PTL entry. The summaryCache sweep
// already covers normal-compaction traffic via fitToWindow; without
// this call here, a conversation that only ever hits PTL never
// releases its ptlForceCompactAt entry.
evictExpiredEntries();
// Race-safe claim: compute is atomic per key, so two concurrent
// PTL hits on the same conv can't both pass the cooldown check.
// The {@code claimed} flag is set inside the atomic block so we can
// distinguish "this call's stamp won" from "previous call's stamp
// happened to equal our now" (Windows clock has 15 ms granularity —
// identity-on-timestamp would misfire for back-to-back invocations).
long now = System.currentTimeMillis();
final boolean[] claimed = {false};
ptlForceCompactAt.compute(conversationId, (k, prev) -> {
if (prev != null && now - prev < PTL_FORCE_LLM_COOLDOWN_MS) {
claimed[0] = false;
return prev;
}
claimed[0] = true;
return now;
});
if (!claimed[0]) {
long prevStamp = ptlForceCompactAt.getOrDefault(conversationId, now);
long remainingMs = Math.max(0L, PTL_FORCE_LLM_COOLDOWN_MS - (now - prevStamp));
log.warn("[ConversationWindow] PTL cooldown active for conv={} (remaining {} ms), falling back to tail-only",
conversationId, remainingMs);
broadcastCompactStatus(conversationId, "ptl_cooldown_skipped", Map.of(
"trigger", "prompt_too_long",
"cooldownRemainingMs", remainingMs));
return compactForRetry(messages);
}
int currentTokens = TokenEstimator.estimateTokens(messages);
// Force the history budget into the bottom quartile of current size
// — but never under 2k so the post-trim window still has room for
// summary + anchor + a couple of recent turns. Tail budget is one
// quarter of that so the recent window doesn't dominate.
int forcedBudget = Math.max(2000, currentTokens / 4);
int forcedTailBudget = forcedBudget / 4;
log.warn("[ConversationWindow] PTL forced compaction: messages={}, currentTokens={}, forcedBudget={}, forcedTail={}",
messages.size(), currentTokens, forcedBudget, forcedTailBudget);
// Note: no separate "ptl_start" broadcast — the inner compactMessages
// call broadcasts "start" with trigger="prompt_too_long" in its
// payload, which is sufficient differentiation for the frontend
// (one event per compaction, with the trigger field carrying the
// semantic distinction).
// Spill count is the manager's private view of toolResultStorage —
// computed inside the manager so callers don't need to touch the
// storage SPI.
long spillsAtEntry = (toolResultStorage != null) ? toolResultStorage.getSpillCount() : 0L;
List compacted = compactMessages(messages, forcedBudget, forcedTailBudget,
chatModel, conversationId, agentId, currentTokens, spillsAtEntry,
"prompt_too_long", null);
if (compacted == messages || TokenEstimator.estimateTokens(compacted) >= currentTokens) {
log.warn("[ConversationWindow] PTL structured compaction had no effect for conv={}, falling back to tail-only",
conversationId);
return compactForRetry(messages);
}
return compacted;
}
/**
* PTL (Prompt Too Long) 恢复用的紧急压缩。
* 不调用 LLM 摘要,直接丢弃较旧消息,只保留最近 4 条。
*/
public List compactForRetry(List messages) {
if (messages == null || messages.size() <= 2) {
return null;
}
int preserveCount = Math.min(4, messages.size());
int splitPoint = messages.size() - preserveCount;
if (splitPoint <= 0) {
return null;
}
List recentMessages = new ArrayList<>(messages.subList(splitPoint, messages.size()));
log.info("[ConversationWindow] PTL 紧急压缩: {} -> {} 条消息 (丢弃 {} 条旧消息)",
messages.size(), recentMessages.size(), splitPoint);
return recentMessages;
}
// ==================== 冷却机制 ====================
private boolean isInSummaryCooldown(String conversationId) {
Long until = summaryCooldownUntil.get(conversationId);
return until != null && System.currentTimeMillis() < until;
}
private void setSummaryCooldown(String conversationId) {
summaryCooldownUntil.put(conversationId, System.currentTimeMillis() + SUMMARY_COOLDOWN_MS);
}
private void clearSummaryCooldown(String conversationId) {
summaryCooldownUntil.remove(conversationId);
}
// ==================== 缓存管理 ====================
private void evictExpiredEntries() {
summaryCache.entrySet().removeIf(entry -> entry.getValue().isExpired(CACHE_TTL_MS));
long ptlCutoff = System.currentTimeMillis() - PTL_FORCE_LLM_COOLDOWN_MS;
ptlForceCompactAt.entrySet().removeIf(entry -> entry.getValue() < ptlCutoff);
}
record CachedSummary(String summary, long createdAt) {
boolean isExpired(long ttlMs) {
return System.currentTimeMillis() - createdAt > ttlMs;
}
}
}