feat(agent): context compression upgrade, 429 retry, iteration limit & repetition fixes

This commit is contained in:
matevip 2026-04-10 00:52:26 +08:00
parent 250a5f6d46
commit ec7ea038e7
22 changed files with 594 additions and 150 deletions

View File

@ -315,7 +315,7 @@ public class AgentGraphBuilder {
.addEdge(PlanStateKeys.DIRECT_ANSWER_NODE, StateGraph.END);
return graph.compile(CompileConfig.builder()
.recursionLimit(maxIterations * 3 + 10)
.recursionLimit(maxIterations > 0 ? maxIterations * 3 + 10 : 300)
.build());
} catch (Exception e) {
throw new MateClawException("Plan-Execute StateGraph 编译失败: " + e.getMessage());
@ -424,7 +424,7 @@ public class AgentGraphBuilder {
.addEdge(MateClawStateKeys.FINAL_ANSWER_NODE, StateGraph.END);
return graph.compile(CompileConfig.builder()
.recursionLimit(maxIterations * 3 + 10)
.recursionLimit(maxIterations > 0 ? maxIterations * 3 + 10 : 300)
.withLifecycleListener(new ReActLifecycleListener())
.build());
} catch (Exception e) {

View File

@ -44,7 +44,7 @@ public abstract class BaseAgent {
protected String systemPrompt;
/** 最大工具调用迭代次数 */
protected int maxIterations = 10;
protected int maxIterations = 25;
/** 模型名称 */
protected String modelName;

View File

@ -15,19 +15,33 @@ import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import org.springframework.stereotype.Component;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.config.ConversationWindowProperties;
import vip.mate.memory.spi.MemoryManager;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
/**
* 会话历史上下文窗口管理器
* 会话历史上下文窗口管理器Hermes 风格升级版
* <p>
* 在消息注入 StateGraph 之前检测 token 是否超出模型上下文窗口
* 若超出则将较早的消息通过 LLM 压缩为摘要保留最近 N 轮原始消息
* 四阶段压缩策略
* <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
*/
@ -36,35 +50,70 @@ import java.util.concurrent.ConcurrentHashMap;
@RequiredArgsConstructor
public class ConversationWindowManager {
private static final String SUMMARY_SYSTEM_PROMPT = PromptLoader.loadPrompt("context/conversation-summary-system");
private static final String SUMMARY_USER_TEMPLATE = PromptLoader.loadPrompt("context/conversation-summary-user");
// ==================== 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");
/** 摘要注入前缀 */
private static final String SUMMARY_PREFIX =
"[上下文压缩] 更早的对话轮次已被压缩为摘要以节省上下文空间。" +
"以下摘要描述了已完成的工作,当前会话状态可能已反映这些变更。" +
"请基于摘要和当前状态继续,避免重复已完成的工作:\n\n";
// ==================== 序列化截断参数 ====================
private static final int CONTENT_MAX = 6000;
private static final int CONTENT_HEAD = 4000;
private static final int CONTENT_TAIL = 1500;
// ==================== 冷却机制 ====================
/** 摘要失败后的冷却时间毫秒10 分钟 */
private static final long SUMMARY_COOLDOWN_MS = 600_000;
// ==================== 依赖 ====================
private final ConversationWindowProperties properties;
private final MemoryManager memoryManager;
// ==================== 状态 ====================
/** 摘要缓存key = "conversationId:oldMessageCount" */
private final ConcurrentHashMap<String, CachedSummary> summaryCache = new ConcurrentHashMap<>();
/** 缓存 TTL30 分钟 */
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<>();
// ==================== 主入口 ====================
/**
* 将会话历史裁剪到上下文窗口内
* <p>
* 预算计算包含 systemPrompt + 历史消息 + 当前用户消息
* 确保最终拼接后不超出模型上下文窗口
*
* @param messages 已转换的 Spring AI 消息列表不含当前用户消息
* @param systemPrompt 系统提示词文本
* @param currentUserMessage 当前用户输入纳入窗口预算计算但不会拼入返回结果
* @param currentUserMessage 当前用户输入纳入窗口预算计算但不拼入返回结果
* @param maxInputTokens 模型最大输入 token0 null 使用全局默认
* @param chatModel 用于生成摘要的 ChatModel
* @param conversationId 会话 ID用于缓存
* @return 裁剪后的消息列表可能包含摘要前缀
* @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) {
String conversationId, Long agentId) {
if (messages == null || messages.isEmpty()) {
return messages;
}
@ -82,47 +131,57 @@ public class ConversationWindowManager {
return messages;
}
log.info("[ConversationWindow] 超阈值: {} tokens (system={}, current={}, history={}) > {} 触发阈值 (max={}), conversationId={}",
log.info("[ConversationWindow] 超阈值: {} tokens (system={}, current={}, history={}) > {} 触发阈值 (max={}), conv={}",
totalTokens, systemTokens, currentMsgTokens, historyTokens,
triggerThreshold, effectiveMax, conversationId);
// 清理过期缓存
evictExpiredEntries();
// 可用于历史的 token 预算 = max - system - currentMsg - 安全余量
int reservedTokens = systemTokens + currentMsgTokens + (int) (effectiveMax * 0.05);
int historyBudget = effectiveMax - reservedTokens;
return compactMessages(messages, historyBudget, chatModel, conversationId);
// 尾部保护 token 预算阈值的 20% Hermes 一致
int tailTokenBudget = (int) (triggerThreshold * 0.20);
return compactMessages(messages, historyBudget, tailTokenBudget, chatModel, conversationId, agentId);
}
private List<Message> compactMessages(List<Message> messages, int historyBudget,
ChatModel chatModel, String conversationId) {
// 计算保留多少条最近消息
int preserveCount = calculatePreserveCount(messages);
/**
* 向后兼容不传 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);
}
// 如果消息总数不够拆分尝试逐步减少保留数
if (preserveCount >= messages.size()) {
// 消息太少无法拆分尝试保留最少 2
preserveCount = Math.min(2, messages.size());
if (preserveCount >= messages.size()) {
log.debug("[ConversationWindow] 消息数 {} 无法拆分,跳过压缩", messages.size());
return messages;
}
// ==================== 核心压缩逻辑 ====================
private List<Message> compactMessages(List<Message> messages, int historyBudget,
int tailTokenBudget, ChatModel chatModel,
String conversationId, Long agentId) {
// 动态计算尾部保护边界替代固定 preserveRecentPairs
int headEnd = 0; // 头部保护暂不保护system prompt 已在外部计算
int tailStart = findTailBoundary(messages, headEnd, tailTokenBudget);
if (tailStart <= headEnd) {
log.debug("[ConversationWindow] 消息数不足以拆分,跳过压缩");
return messages;
}
int splitPoint = messages.size() - preserveCount;
List<Message> oldMessages = new ArrayList<>(messages.subList(0, splitPoint)); // 可变副本
List<Message> recentMessages = messages.subList(splitPoint, messages.size());
List<Message> oldMessages = new ArrayList<>(messages.subList(headEnd, tailStart));
List<Message> recentMessages = messages.subList(tailStart, messages.size());
// Phase 1: Soft Trim 裁剪工具结果head+tail避免不必要的 LLM 摘要
// Phase 1: Soft Trim 裁剪工具结果
int softTrimmed = softTrimToolResults(oldMessages);
if (softTrimmed > 0) {
int afterTrimTokens = TokenEstimator.estimateTokens(oldMessages) + TokenEstimator.estimateTokens(recentMessages);
log.info("[ConversationWindow] Soft trim: {} tool results trimmed, tokens now={}, budget={}",
log.info("[ConversationWindow] Phase 1 Soft trim: {} tool results trimmed, tokens={}, budget={}",
softTrimmed, afterTrimTokens, historyBudget);
if (afterTrimTokens <= historyBudget) {
// Soft trim 够了跳过 LLM 摘要
List<Message> result = new ArrayList<>(oldMessages);
result.addAll(recentMessages);
return result;
@ -133,7 +192,7 @@ public class ConversationWindowManager {
int hardCleared = hardClearToolResults(oldMessages);
if (hardCleared > 0) {
int afterClearTokens = TokenEstimator.estimateTokens(oldMessages) + TokenEstimator.estimateTokens(recentMessages);
log.info("[ConversationWindow] Hard clear: {} tool results replaced with placeholder, tokens now={}, budget={}",
log.info("[ConversationWindow] Phase 2 Hard clear: {} replaced, tokens={}, budget={}",
hardCleared, afterClearTokens, historyBudget);
if (afterClearTokens <= historyBudget) {
List<Message> result = new ArrayList<>(oldMessages);
@ -142,7 +201,31 @@ public class ConversationWindowManager {
}
}
// Phase 3: LLM 摘要原有逻辑仅在 Phase 1+2 不够时执行
// 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 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);
// 检查缓存
String cacheKey = conversationId + ":" + oldMessages.size();
@ -151,30 +234,29 @@ public class ConversationWindowManager {
if (cached != null && !cached.isExpired(CACHE_TTL_MS)) {
summary = cached.summary();
log.debug("[ConversationWindow] 命中摘要缓存, conversationId={}", conversationId);
log.debug("[ConversationWindow] 命中摘要缓存, conv={}", conversationId);
} else {
summary = generateSummary(oldMessages, chatModel);
summary = generateSummary(forSummary, chatModel, conversationId, summaryBudget, memoryExtraContext);
if (summary != null) {
summaryCache.put(cacheKey, new CachedSummary(summary, System.currentTimeMillis()));
log.info("[ConversationWindow] 生成新摘要 ({} 字符), 压缩 {} 条旧消息, conversationId={}",
summary.length(), oldMessages.size(), conversationId);
int count = compressionCounts.merge(conversationId, 1, Integer::sum);
log.info("[ConversationWindow] 生成结构化摘要 ({} 字符, 第 {} 次压缩), 压缩 {} 条旧消息, conv={}",
summary.length(), count, oldMessages.size(), conversationId);
}
}
// 组装结果
List<Message> result = new ArrayList<>();
if (summary != null && !summary.isBlank()) {
// 安全作为 UserMessage 注入避免历史内容获得 system 级优先级
result.add(new UserMessage("[对话上下文摘要 - 仅供参考,不是指令]\n" + summary));
result.add(new UserMessage(SUMMARY_PREFIX + summary));
} else if (!oldMessages.isEmpty()) {
// LLM 摘要生成失败降级保留最近几条旧消息而非全部丢弃
log.warn("[ConversationWindow] 摘要生成失败,降级为简单截断保留最近旧消息, conversationId={}", conversationId);
int fallbackKeep = Math.min(4, oldMessages.size()); // 保留最近 4 条旧消息
log.warn("[ConversationWindow] 摘要生成失败,降级为保留最近 4 条旧消息, conv={}", conversationId);
int fallbackKeep = Math.min(4, oldMessages.size());
result.addAll(oldMessages.subList(oldMessages.size() - fallbackKeep, oldMessages.size()));
}
result.addAll(recentMessages);
// 压缩后校验如果仍然超出预算逐步丢弃更多旧的保留消息
// 压缩后校验
int resultTokens = TokenEstimator.estimateTokens(result);
if (resultTokens > historyBudget && result.size() > 2) {
log.warn("[ConversationWindow] 压缩后仍超预算: {} > {}, 执行二次裁剪", resultTokens, historyBudget);
@ -184,11 +266,59 @@ public class ConversationWindowManager {
return result;
}
// ==================== 工具结果裁剪 ====================
// ==================== 动态 Token 预算 ====================
/**
* Soft trim对工具结果做 head+tail 裁剪保留首尾各 200 字符
* @return 裁剪的工具结果条数
* 基于 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);
}
/**
* 计算摘要字数预算被压缩内容 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()));
}
// ==================== 工具结果处理 ====================
/**
* Phase 1 - Soft trim对工具结果做 head+tail 裁剪保留首尾各 200 字符
*/
private int softTrimToolResults(List<Message> messages) {
int trimmed = 0;
@ -218,8 +348,7 @@ public class ConversationWindowManager {
}
/**
* Hard clear将所有工具结果替换为占位符
* @return 替换的工具结果条数
* Phase 2 - Hard clear将所有旧工具结果替换为占位符
*/
private int hardClearToolResults(List<Message> messages) {
int cleared = 0;
@ -235,9 +364,137 @@ public class ConversationWindowManager {
return cleared;
}
/**
* Phase 3 Pre-prune LLM 摘要前将工具输出替换为占位符减少摘要输入 token
*/
private int prePruneForSummary(List<Message> messages) {
int pruned = 0;
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof ToolResponseMessage trm) {
boolean hasSubstantial = trm.getResponses().stream()
.anyMatch(r -> r.responseData() != null && r.responseData().length() > 200);
if (hasSubstantial) {
List<ToolResponseMessage.ToolResponse> placeholders = trm.getResponses().stream()
.map(r -> new ToolResponseMessage.ToolResponse(r.id(), r.name(),
"[旧工具输出已清理以节省上下文空间]"))
.toList();
messages.set(i, ToolResponseMessage.builder().responses(placeholders).build());
pruned++;
}
}
}
return pruned;
}
// ==================== 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;
if (previousSummary != null) {
// 迭代更新模式旧摘要 + 新轮次
systemPrompt = STRUCTURED_SUMMARY_SYSTEM;
userPrompt = STRUCTURED_SUMMARY_UPDATE
.replace("{previous_summary}", previousSummary)
.replace("{conversation}", conversationText)
.replace("{summary_budget}", String.valueOf(summaryBudget));
log.debug("[ConversationWindow] 使用迭代更新模式(第 {} 次压缩), conv={}",
compressionCounts.getOrDefault(conversationId, 0) + 1, conversationId);
} else {
// 首次压缩
systemPrompt = STRUCTURED_SUMMARY_SYSTEM
.replace("{summary_budget}", String.valueOf(summaryBudget));
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 options = DashScopeChatOptions.builder()
.withMaxToken(properties.getSummaryMaxTokens())
.build();
ChatResponse response = chatModel.call(new Prompt(promptMessages, options));
if (response != null && response.getResult() != null
&& response.getResult().getOutput() != 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) {
text = text.substring(0, CONTENT_HEAD)
+ "\n...[截断 " + text.length() + " 字符]...\n"
+ text.substring(text.length() - CONTENT_TAIL);
}
sb.append(role).append(": ").append(text != null ? text : "").append("\n\n");
}
return sb.toString();
}
// ==================== 辅助方法 ====================
/**
* 二次裁剪从前往后移除消息直到 token 预算满足
* 至少保留最后 2 条消息最近一轮对话
*/
private List<Message> trimToFit(List<Message> messages, int budget) {
int startIndex = 0;
@ -255,77 +512,17 @@ public class ConversationWindowManager {
return messages;
}
/**
* 计算应保留的最近消息条数
* 保留 N 轮对话每轮 = user + assistant = 2 至少保留 2
*/
private int calculatePreserveCount(List<Message> messages) {
int pairCount = properties.getPreserveRecentPairs();
int preserveCount = pairCount * 2;
return Math.max(2, Math.min(preserveCount, messages.size()));
}
/**
* 调用 LLM 生成会话摘要使用 summaryMaxTokens 约束输出长度
* 失败时返回 null降级为朴素截断
*/
private String generateSummary(List<Message> oldMessages, ChatModel chatModel) {
try {
StringBuilder conversationText = new StringBuilder();
for (Message msg : oldMessages) {
String role = switch (msg) {
case UserMessage ignored -> "用户";
case SystemMessage ignored -> "系统";
default -> "助手";
};
String text = msg.getText();
// 单条消息截断避免摘要 prompt 本身过长
if (text != null && text.length() > 2000) {
text = text.substring(0, 2000) + "...[已截断]";
}
conversationText.append(role).append(": ").append(text).append("\n\n");
}
String userPrompt = SUMMARY_USER_TEMPLATE
.replace("{conversation}", conversationText.toString());
List<Message> promptMessages = new ArrayList<>();
promptMessages.add(new SystemMessage(SUMMARY_SYSTEM_PROMPT));
promptMessages.add(new UserMessage(userPrompt));
// 使用 summaryMaxTokens 约束摘要输出长度
ChatOptions options = DashScopeChatOptions.builder()
.withMaxToken(properties.getSummaryMaxTokens())
.build();
ChatResponse response = chatModel.call(new Prompt(promptMessages, options));
if (response != null && response.getResult() != null
&& response.getResult().getOutput() != null) {
return response.getResult().getOutput().getText();
}
log.warn("[ConversationWindow] LLM 摘要返回空结果");
return null;
} catch (Exception e) {
log.warn("[ConversationWindow] LLM 摘要生成失败,降级为朴素截断: {}", e.getMessage());
return null;
}
}
// ==================== PTL 紧急压缩 ====================
/**
* PTL (Prompt Too Long) 恢复用的紧急压缩
* <p>
* LLM 返回 context_length_exceeded 错误时 Node 层调用此方法
* 对消息列表做更激进的裁剪保留最近 2 + 朴素截断不调用 LLM 摘要
*
* @param messages 原始消息列表
* @return 压缩后的消息列表如果无法压缩返回 null
* 不调用 LLM 摘要直接丢弃较旧消息只保留最近 4
*/
public List<Message> compactForRetry(List<Message> messages) {
if (messages == null || messages.size() <= 2) {
return null;
}
// 紧急模式不调用 LLM 摘要直接丢弃较旧消息只保留最近 2 (4 )
int preserveCount = Math.min(4, messages.size());
int splitPoint = messages.size() - preserveCount;
@ -339,16 +536,27 @@ public class ConversationWindowManager {
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));
}
/**
* 缓存条目
*/
record CachedSummary(String summary, long createdAt) {
boolean isExpired(long ttlMs) {
return System.currentTimeMillis() - createdAt > ttlMs;

View File

@ -15,6 +15,7 @@ import java.util.List;
import java.util.Map;
import java.util.concurrent.CancellationException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
@ -97,9 +98,9 @@ public class NodeStreamingChatHelper {
// ==================== 重试配置 ====================
private static final int MAX_RETRIES = 3;
private static final long BACKOFF_BASE_MS = 1000;
private static final long BACKOFF_CAP_MS = 10_000;
private static final int MAX_RETRIES = 5;
private static final long BACKOFF_BASE_MS = 3000;
private static final long BACKOFF_CAP_MS = 60_000;
/**
* 判断错误是否可重试基于状态码/异常类型
@ -246,8 +247,16 @@ public class NodeStreamingChatHelper {
boolean broadcast, int attempt) {
if (attempt > 0) {
long delay = Math.min(BACKOFF_BASE_MS * (1L << (attempt - 1)), BACKOFF_CAP_MS);
// 加入 jitter 防止雷群效应Hermes 风格
delay += ThreadLocalRandom.current().nextLong(0, Math.max(1, delay / 2));
delay = Math.min(delay, BACKOFF_CAP_MS);
log.warn("[{}] Retry attempt {}/{} after {}ms for conversation {}",
phase, attempt, MAX_RETRIES, delay, conversationId);
// 广播给前端用户可见的重试倒计时
if (broadcast) {
broadcastDelta(conversationId, "warning",
buildDeltaJson("⏱️ 请求频率受限,等待 " + (delay / 1000) + " 秒后重试(第 " + attempt + "/" + MAX_RETRIES + " 次)..."));
}
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {

View File

@ -83,6 +83,11 @@ public class RepetitionDetector {
}
if (count >= MIN_REPEATS) {
// 排除装饰性重复代码缩进ASCII 图表Markdown 分隔线常见
if (isDecorativePattern(pattern)) {
continue;
}
repetitionDetected = true;
log.warn("[RepetitionDetector] Detected degenerate repetition: " +
"pattern length={}, repeats={}, pattern preview=\"{}\"",
@ -95,6 +100,46 @@ public class RepetitionDetector {
return false;
}
/**
* 判断 pattern 是否为装饰性字符不应判定为退化重复
* <p>
* 排除场景
* <ul>
* <li>纯空白/缩进{@code " "}代码缩进</li>
* <li>单一重复字符{@code "────────"} {@code "════════"} {@code "--------"} {@code "********"}分隔线表格边框</li>
* <li>Box Drawing 字符族{@code "┌──────┐"} {@code "│ │"}ASCII 图表</li>
* </ul>
*/
private boolean isDecorativePattern(String pattern) {
if (pattern.isBlank()) {
return true; // 纯空白
}
// 统计不同的非空白字符种类
long distinctNonWhitespace = pattern.chars()
.filter(c -> !Character.isWhitespace(c))
.distinct()
.count();
// 只有 1-2 种不同的非空白字符 装饰性 "────────" "│ │"
if (distinctNonWhitespace <= 2) {
return true;
}
// 检查是否全部是 Box Drawing / 装饰字符
boolean allDecorative = pattern.chars().allMatch(c ->
Character.isWhitespace(c)
|| isBoxDrawing(c)
|| "─━│┃┄┅┆┇┈┉┊┋═║╌╍╎╏╔╗╚╝╠╣╦╩╬├┤┬┴┼┌┐└┘".indexOf(c) >= 0
|| "-=_*+|#~<>".indexOf(c) >= 0);
return allDecorative;
}
private boolean isBoxDrawing(int codePoint) {
// Unicode Box Drawing block: U+2500 U+257F
return codePoint >= 0x2500 && codePoint <= 0x257F;
}
/**
* 重置检测器状态
*/

View File

@ -341,13 +341,16 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
// 上下文窗口管理裁剪超出模型 context window 的历史含当前消息预算
if (conversationWindowManager != null) {
Long parsedAgentId = null;
try { parsedAgentId = Long.valueOf(agentId); } catch (Exception ignored) {}
historyMessages = conversationWindowManager.fitToWindow(
historyMessages,
systemPrompt != null ? systemPrompt : "",
userMessage,
maxInputTokens,
chatModel,
conversationId);
conversationId,
parsedAgentId);
}
List<Message> messages = new ArrayList<>(historyMessages);

View File

@ -38,8 +38,8 @@ public class ObservationDispatcher implements EdgeAction {
return FINAL_ANSWER_NODE;
}
// 1. 迭代超限检查
if (currentIteration >= maxIterations) {
// 1. 迭代超限检查maxIterations=0 表示不限制
if (maxIterations > 0 && currentIteration >= maxIterations) {
log.warn("[ObservationDispatcher] Max iterations ({}) reached at iteration {}, " +
"routing to limitExceededNode", maxIterations, currentIteration);
return LIMIT_EXCEEDED_NODE;

View File

@ -52,9 +52,10 @@ public class ReasoningDispatcher implements EdgeAction {
return FINAL_ANSWER_NODE;
}
// 3. LLM 调用次数超限 仅拦截继续循环工具调用/总结的路径
// 3. LLM 调用次数超限 仅拦截继续循环工具调用/总结的路径maxIterations=0 不限制
int llmCallCount = accessor.llmCallCount();
int llmCallLimit = accessor.maxIterations() * LLM_CALL_MULTIPLIER;
int maxIter = accessor.maxIterations();
int llmCallLimit = maxIter > 0 ? maxIter * LLM_CALL_MULTIPLIER : Integer.MAX_VALUE;
if (llmCallCount >= llmCallLimit) {
log.warn("[ReasoningDispatcher] LLM call count limit reached ({}/{}), " +
"routing to limitExceededNode instead of continuing loop",

View File

@ -71,6 +71,24 @@ public class ObservationNode implements NodeAction {
// 合并为单条观察记录
String combinedObservation = String.join("\n---\n", processedObservations);
// Budget Pressure WarningHermes 风格接近上限时注入警告到工具结果中
// LLM 下一轮 reasoning 时能看到从而主动收束而非被硬性截断
if (maxIterations > 0) {
int progress = (int) ((double) nextIteration / maxIterations * 100);
if (progress >= 90) {
combinedObservation += "\n\n[⚠️ 预算警告] 当前迭代 " + nextIteration + "/" + maxIterations +
",仅剩 " + (maxIterations - nextIteration) + " 步。" +
"请立即提供最终回答,不要再调用工具(除非绝对必要)。";
log.info("[ObservationNode] Budget WARNING injected: {}/{} ({}%)",
nextIteration, maxIterations, progress);
} else if (progress >= 70) {
combinedObservation += "\n\n[📊 预算提示] 当前迭代 " + nextIteration + "/" + maxIterations +
",剩余 " + (maxIterations - nextIteration) + " 步。请开始整合已有信息,准备给出回答。";
log.info("[ObservationNode] Budget caution injected: {}/{} ({}%)",
nextIteration, maxIterations, progress);
}
}
// 手动累加观察历史OBSERVATION_HISTORY 使用 REPLACE 策略以便 SummarizingNode 可清空
List<String> existingHistory = accessor.observationHistory();
List<String> updatedHistory = new ArrayList<>(existingHistory);

View File

@ -245,13 +245,16 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
// 上下文窗口管理裁剪超出模型 context window 的历史含当前消息预算
if (conversationWindowManager != null) {
Long parsedAgentId = null;
try { parsedAgentId = Long.valueOf(agentId); } catch (Exception ignored) {}
historyMessages = conversationWindowManager.fitToWindow(
historyMessages,
systemPrompt != null ? systemPrompt : "",
userMessage,
maxInputTokens,
chatModel,
conversationId);
conversationId,
parsedAgentId);
}
List<Message> messages = new ArrayList<>(historyMessages);

View File

@ -57,11 +57,12 @@ public final class MateClawStateAccessor {
}
public int maxIterations() {
return state.value(MAX_ITERATIONS, 10);
return state.value(MAX_ITERATIONS, 25);
}
public boolean isLimitReached() {
return iterationCount() >= maxIterations();
int max = maxIterations();
return max > 0 && iterationCount() >= max; // max=0 表示不限制
}
// ===== 工具调用 =====

View File

@ -21,6 +21,20 @@ public class ConversationWindowProperties {
/** 压缩后保留最近 N 轮对话user+assistant 算一轮) */
private int preserveRecentPairs = 5;
/** 摘要自身最大 token 数 */
/** 摘要自身最大 token 数(仅作 LLM maxToken 参数上限,实际预算由动态计算) */
private int summaryMaxTokens = 800;
// ==================== 动态压缩配置Hermes 风格 ====================
/** 尾部保护的最小消息数(即使 token 预算用完也至少保留这么多) */
private int protectLastMinMessages = 10;
/** 摘要 token 预算占被压缩内容的比例 (0-1)。被压缩内容越多,摘要越长。 */
private double summaryBudgetRatio = 0.20;
/** 摘要 token 预算上限(字数,非 token */
private int summaryBudgetCeiling = 3000;
/** 摘要 token 预算下限(字数) */
private int summaryBudgetFloor = 500;
}

View File

@ -108,7 +108,7 @@ public class MemoryNudgeService {
.replace("{transcript}", transcript)
.replace("{existing_memories}", existingMemories.isBlank() ? "(none)" : existingMemories);
// 5. Call LLM
// 5. Call LLM (with rate limit retry)
String llmResponse;
try {
ChatModel chatModel = buildChatModel();
@ -116,8 +116,11 @@ public class MemoryNudgeService {
new SystemMessage(systemPrompt),
new UserMessage(userPrompt)
));
ChatResponse response = chatModel.call(prompt);
llmResponse = response.getResult().getOutput().getText();
llmResponse = callLlmWithRetry(chatModel, prompt, 2);
if (llmResponse == null) {
log.warn("[Nudge] LLM returned null after retries for agent={}", agentId);
return;
}
} catch (Exception e) {
log.warn("[Nudge] LLM call failed for agent={}: {}", agentId, e.getMessage());
return;
@ -193,6 +196,38 @@ public class MemoryNudgeService {
}
}
private String callLlmWithRetry(ChatModel chatModel, Prompt prompt, int maxRetries) {
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
ChatResponse response = chatModel.call(prompt);
if (response != null && response.getResult() != null
&& response.getResult().getOutput() != null) {
return response.getResult().getOutput().getText();
}
return null;
} catch (Exception e) {
if (attempt < maxRetries && isRateLimitError(e)) {
long delay = 5000L * (attempt + 1);
log.info("[Nudge] Rate limited, waiting {}ms before retry ({}/{})",
delay, attempt + 1, maxRetries);
try { Thread.sleep(delay); } catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return null;
}
} else {
throw e instanceof RuntimeException re ? re : new RuntimeException(e);
}
}
}
return null;
}
private boolean isRateLimitError(Exception e) {
String msg = e.getMessage();
return msg != null && (msg.contains("429") || msg.contains("rate_limit")
|| msg.contains("速率限制") || msg.contains("Too Many Requests"));
}
private boolean isInCooldown(Long agentId) {
Instant lastRun = lastNudgeTimes.get(agentId);
if (lastRun == null) return false;

View File

@ -120,8 +120,11 @@ public class MemorySummarizationService {
new SystemMessage(systemPrompt),
new UserMessage(userPrompt)
));
ChatResponse response = chatModel.call(prompt);
llmResponse = response.getResult().getOutput().getText();
llmResponse = callLlmWithRetry(chatModel, prompt, 2);
if (llmResponse == null) {
log.warn("[Memory] LLM returned null after retries for agent={}, conv={}", agentId, conversationId);
return;
}
} catch (Exception e) {
log.warn("[Memory] LLM call failed for agent={}, conv={}: {}",
agentId, conversationId, e.getMessage());
@ -247,6 +250,42 @@ public class MemorySummarizationService {
}
}
/**
* 带轻量重试的 LLM 调用遇到 429 时等待后重试避免后台任务因限流直接放弃
* Spring AI RetryTemplate 已处理第一层重试此方法作为二次保护
*/
private String callLlmWithRetry(ChatModel chatModel, Prompt prompt, int maxRetries) {
for (int attempt = 0; attempt <= maxRetries; attempt++) {
try {
ChatResponse response = chatModel.call(prompt);
if (response != null && response.getResult() != null
&& response.getResult().getOutput() != null) {
return response.getResult().getOutput().getText();
}
return null;
} catch (Exception e) {
if (attempt < maxRetries && isRateLimitError(e)) {
long delay = 5000L * (attempt + 1);
log.info("[Memory] Rate limited, waiting {}ms before retry ({}/{})",
delay, attempt + 1, maxRetries);
try { Thread.sleep(delay); } catch (InterruptedException ie) {
Thread.currentThread().interrupt();
return null;
}
} else {
throw e instanceof RuntimeException re ? re : new RuntimeException(e);
}
}
}
return null;
}
private boolean isRateLimitError(Exception e) {
String msg = e.getMessage();
return msg != null && (msg.contains("429") || msg.contains("rate_limit")
|| msg.contains("速率限制") || msg.contains("Too Many Requests"));
}
private boolean isInCooldown(Long agentId) {
Instant lastRun = lastRunTimes.get(agentId);
if (lastRun == null) return false;

View File

@ -45,6 +45,14 @@ spring:
repository:
jdbc:
initialize-schema: embedded
# Spring AI Retry 配置429/503/529 归为可重试TransientAiException启用指数退避
retry:
max-attempts: 5
on-http-codes: 429, 503, 529
backoff:
initial-interval: 3000
multiplier: 3
max-interval: 60000
# 禁用 Spring AI MCP Client 自动配置(由 McpClientManager 自行管理生命周期)
mcp:
client:

View File

@ -10,7 +10,7 @@ MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_n
KEY (id)
VALUES (1000000001, 'MateClaw Assistant', 'Default AI assistant with ReAct mode and tool calling', 'react',
'You are MateClaw, an intelligent AI assistant. You can help users answer questions, analyze data, and execute tasks. Please respond professionally and in a friendly manner.',
NULL, 10, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0);
NULL, 25, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0);
-- Default Agent: Task Planner (Plan-Execute mode)
MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
@ -24,7 +24,7 @@ MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_n
KEY (id)
VALUES (1000000003, 'StateGraph ReAct', 'StateGraph-based ReAct Agent with explicit reasoning loops and tool calling', 'react',
'You are an intelligent assistant based on the StateGraph architecture. You can use tools to help users solve problems. Please respond professionally and in a friendly manner.',
NULL, 10, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0);
NULL, 25, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0);
-- ==================== Local Model Providers (displayed first) ====================

View File

@ -9,7 +9,7 @@ ON DUPLICATE KEY UPDATE username=VALUES(username), password=VALUES(password), ni
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000001, 'MateClaw Assistant', 'Default AI assistant with ReAct mode and tool calling', 'react',
'You are MateClaw, an intelligent AI assistant. You can help users answer questions, analyze data, and execute tasks. Please respond professionally and in a friendly manner.',
NULL, 10, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0)
NULL, 25, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- Default Agent: Task Planner (Plan-Execute mode)
@ -23,7 +23,7 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agen
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000003, 'StateGraph ReAct', 'StateGraph-based ReAct Agent with explicit reasoning loops and tool calling', 'react',
'You are an intelligent assistant based on the StateGraph architecture. You can use tools to help users solve problems. Please respond professionally and in a friendly manner.',
NULL, 10, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0)
NULL, 25, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- ==================== Local Model Providers (displayed first) ====================

View File

@ -9,7 +9,7 @@ ON DUPLICATE KEY UPDATE username=VALUES(username), password=VALUES(password), ni
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000001, 'MateClaw Assistant', '默认 AI 助手,基于 ReAct 模式,支持工具调用', 'react',
'你是 MateClaw一个智能 AI 助手。你可以帮助用户回答问题、分析数据、执行任务。请用中文回复,保持专业、友好的态度。',
NULL, 10, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0)
NULL, 25, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- 默认 Agent任务规划助手Plan-Execute 模式)
@ -23,7 +23,7 @@ ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agen
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000003, 'StateGraph ReAct', '基于 StateGraph 的 ReAct Agent支持显式推理循环和工具调用', 'react',
'你是基于 StateGraph 架构的智能助手。你可以使用工具来帮助用户解决问题。请用中文回复,保持专业、友好的态度。',
NULL, 10, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0)
NULL, 25, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- ==================== 本地模型 Provider优先展示 ====================

View File

@ -10,7 +10,7 @@ MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_n
KEY (id)
VALUES (1000000001, 'MateClaw Assistant', '默认 AI 助手,基于 ReAct 模式,支持工具调用', 'react',
'你是 MateClaw一个智能 AI 助手。你可以帮助用户回答问题、分析数据、执行任务。请用中文回复,保持专业、友好的态度。',
NULL, 10, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0);
NULL, 25, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0);
-- 默认 Agent任务规划助手Plan-Execute 模式)
MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
@ -24,7 +24,7 @@ MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_n
KEY (id)
VALUES (1000000003, 'StateGraph ReAct', '基于 StateGraph 的 ReAct Agent支持显式推理循环和工具调用', 'react',
'你是基于 StateGraph 架构的智能助手。你可以使用工具来帮助用户解决问题。请用中文回复,保持专业、友好的态度。',
NULL, 10, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0);
NULL, 25, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0);
-- ==================== 本地模型 Provider优先展示 ====================

View File

@ -0,0 +1,32 @@
你是上下文压缩助手。将以下对话轮次压缩为结构化交接摘要,供后续助手继续任务。
使用以下结构:
## 目标
[用户要完成什么]
## 约束与偏好
[用户偏好、编码风格、重要决策]
## 进展
### 已完成
[已完成的工作——包含具体文件路径、执行的命令、获得的结果]
### 进行中
[当前正在进行的工作]
### 阻塞
[遇到的阻塞或问题]
## 关键决策
[重要的技术决策及原因]
## 相关文件
[读取、修改或创建的文件——每个附简要说明]
## 下一步
[继续工作需要做什么]
## 关键上下文
[不显式保留就会丢失的具体值、错误消息、配置详情]
目标约 {summary_budget} 字。要具体——包含文件路径、命令输出、错误消息和实际值。
只输出摘要正文,不要前缀或额外说明。

View File

@ -0,0 +1,23 @@
你正在更新一个上下文压缩摘要。上一次压缩生成了以下摘要,之后发生了新的对话轮次。
## 旧摘要
{previous_summary}
## 新增轮次
{conversation}
请用同样的结构更新摘要。保留所有仍然相关的信息,添加新进展,
将"进行中"的已完成项移到"已完成",仅删除明显过时的信息。
使用以下结构:
## 目标
## 约束与偏好
## 进展(已完成 / 进行中 / 阻塞)
## 关键决策
## 相关文件
## 下一步
## 关键上下文
目标约 {summary_budget} 字。要具体——包含文件路径、命令输出、错误消息和实际值。
只输出摘要正文,不要前缀或额外说明。

View File

@ -0,0 +1,5 @@
以下是需要压缩的对话轮次:
{conversation}
请生成结构化交接摘要。