mateclaw/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java

1660 lines
80 KiB
Java
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.

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;
/**
* 会话历史上下文窗口管理器(四阶段压缩升级版)
* <p>
* 四阶段压缩策略:
* <ol>
* <li>Soft Trim — 裁剪旧工具结果为 head+tail</li>
* <li>Hard Clear — 替换所有旧工具结果为占位符</li>
* <li>Pre-Prune — 喂给摘要 LLM 前清理工具输出(减少摘要输入 token)</li>
* <li>LLM 结构化摘要 — Goal/Progress/Decisions/Files/NextSteps 模板,支持迭代更新</li>
* </ol>
* <p>
* 关键特性:
* <ul>
* <li>迭代摘要更新:多轮压缩时将旧摘要 + 新轮次合并,信息不丢失</li>
* <li>动态 Token 预算:基于模型上下文长度计算尾部保护和摘要预算</li>
* <li>压缩冷却机制:摘要失败后 10 分钟内不重试,防止雪崩</li>
* <li>MemoryProvider 钩子:压缩前通知记忆 provider 提取关键信息</li>
* </ul>
* <p>
* 安全设计:摘要内容作为 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.
*
* <p>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.
*
* <p>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.
*
* <p>{@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.
*
* <p>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<String> 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<String, CachedSummary> summaryCache = new ConcurrentHashMap<>();
private static final long CACHE_TTL_MS = 30 * 60 * 1000L;
/** 迭代摘要:上一次压缩生成的摘要文本(per conversation) */
private final ConcurrentHashMap<String, String> previousSummaries = new ConcurrentHashMap<>();
/** 每个会话的压缩次数 */
private final ConcurrentHashMap<String, Integer> compressionCounts = new ConcurrentHashMap<>();
/** 每个会话的摘要冷却截止时间 */
private final ConcurrentHashMap<String, Long> 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<String, Long> 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<Message> fitToWindow(List<Message> 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<Message> fitToWindow(List<Message> messages, String systemPrompt,
String currentUserMessage,
Integer maxInputTokens, ChatModel chatModel,
String conversationId, Long agentId,
java.util.Collection<ToolCallback> 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}.
*
* <p>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<Message> fitToWindow(List<Message> messages, String systemPrompt,
String currentUserMessage,
Integer maxInputTokens, ChatModel chatModel,
String conversationId, Long agentId,
java.util.Collection<ToolCallback> 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<Message> 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<Message> fitToWindow(List<Message> 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<String, Object> 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<String, Object> extra) {
if (streamTracker == null || conversationId == null || conversationId.isEmpty()) {
return;
}
try {
Map<String, Object> 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<Message> compactMessages(List<Message> 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<Message> oldMessages = new ArrayList<>(messages.subList(headEnd, tailStart));
List<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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<Message> 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<String, Object> 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<Message> 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.
*
* <p>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.
*
* <p>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.
*
* <p>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<Message> 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<String, Integer> assistantIdxById = new java.util.HashMap<>();
// Map: tool_call_id -> max response index closing it.
java.util.Map<String, Integer> 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 <em>real</em> 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.
*
* <p>Sizing rules:
* <ul>
* <li>≤ {@code firstUserAnchorMaxTokens}: keep the original text verbatim.</li>
* <li>≤ 3× the budget: head+tail truncate to the budget so most of
* the prompt-cache benefit survives.</li>
* <li>&gt; 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.</li>
* </ul>
*
* <p>Always returns a {@link UserMessage}. {@code null} when anchoring
* is disabled, no real first user exists in the prefix, or the body is
* blank.
*
* <p>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<Message> 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<Message> 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<Message> pruneOldToolResultsForModelInput(List<Message> 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:
*
* <ol>
* <li>Bodies already starting with {@link ToolResultStorage#SPILL_MARKER_PREFIX}
* were spilled at tool-execution time — pass through untouched.</li>
* <li>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).</li>
* <li>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.</li>
* <li>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.</li>
* </ol>
*
* <p>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<Message> pruneOldToolResultsForModelInput(List<Message> 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<Message> pruned = new ArrayList<>(messages);
java.util.Set<String> 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<ToolResponseMessage.ToolResponse> 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.
*
* <p>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.
*
* <p>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<Message> compactAgedToolResponses(List<Message> messages, int keepRecentN) {
if (messages == null || messages.isEmpty() || keepRecentN <= 0) {
return messages;
}
List<Message> 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<ToolResponseMessage.ToolResponse> 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 字符)。
* <p>Spill-marker responses are left untouched so their on-disk pointer
* survives intact across compaction.
*/
int softTrimToolResults(List<Message> messages) {
int trimmed = 0;
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof ToolResponseMessage trm) {
List<ToolResponseMessage.ToolResponse> 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:将所有旧工具结果替换为占位符。
* <p>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<Message> messages) {
int cleared = 0;
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof ToolResponseMessage trm) {
boolean changed = false;
List<ToolResponseMessage.ToolResponse> 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)。
* <p>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<Message> 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<ToolResponseMessage.ToolResponse> 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.
*
* <p>Unlike Phase 1/2 (which trim/clear in place), this phase is
* <em>lossless</em>: 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.
*
* <p>Skips:
* <ul>
* <li>Exempt tools ({@link #PRUNE_EXEMPT_TOOLS}) — not safely
* recoverable.</li>
* <li>Already-spilled markers — re-spilling would just overwrite the
* same file.</li>
* <li>Bodies under {@code perResultThresholdChars} — too small to
* benefit from spilling (the marker preview is comparable in size).</li>
* </ul>
*
* @return number of tool responses actually spilled to disk
*/
int spillEvictToolResults(List<Message> 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<ToolResponseMessage.ToolResponse> 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<Message> 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<Message> 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<Message> 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<Message> trimToFit(List<Message> 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.
* <p>
* Differences vs the {@link #compactForRetry(List)} fallback:
* <ul>
* <li>Preserves the original user goal via anchor instead of dropping
* it with the head — long tasks lose context every PTL otherwise.</li>
* <li>Pair-safe cuts, so the retry doesn't break a
* {@code AssistantMessage.tool_calls} / {@code ToolResponseMessage}
* cluster and 400 the provider a second time.</li>
* <li>Runs through summary generation so semantic continuity (user
* preferences, completed steps) survives the trim.</li>
* <li>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.</li>
* </ul>
* <p>
* 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<Message> compactForRetry(List<Message> 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<Message> 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<Message> compactForRetry(List<Message> 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<Message> 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;
}
}
}