diff --git a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java index 26d958fd..bee8e7a1 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/BaseAgent.java @@ -241,16 +241,40 @@ public abstract class BaseAgent { // boundary's content is already folded into the newer summary. Walking // forward and breaking on the first boundary kept everything between // boundaries — the very redundancy compaction was supposed to remove. + boolean boundaryFoundInWindow = false; for (int i = history.size() - 1; i >= 0; i--) { MessageEntity msg = history.get(i); if ("system".equals(msg.getRole()) && isCompressionSummary(msg)) { history = new ArrayList<>(history.subList(i, history.size())); + boundaryFoundInWindow = true; log.info("[{}] Found latest compression boundary at index {}; loading {} messages forward", agentName, i, history.size()); break; } } + // ===== Latest boundary may live OUTSIDE the recent window ===== + // On a long conversation that compacted hours/days ago and has paged + // fewer than `windowSize` new messages since, `listRecentMessages` + // returns only the raw tail — the boundary sat at index 0 of the + // original list and never made it into `history`. Without prepending + // it, the model would forget the original goal even though we already + // paid the LLM cost to produce a structured summary. + if (!boundaryFoundInWindow && totalCount > windowSize) { + try { + MessageEntity latestBoundary = conversationService.findLatestCompressionBoundary(conversationId); + if (latestBoundary != null) { + history = new ArrayList<>(history); + history.add(0, latestBoundary); + log.info("[{}] Prepended out-of-window compression boundary id={} so the model keeps the summary context", + agentName, latestBoundary.getId()); + } + } catch (Exception e) { + log.warn("[{}] findLatestCompressionBoundary failed; loading recent window without boundary: {}", + agentName, e.getMessage()); + } + } + // ===== 转换为 Spring AI Message 对象 ===== int limit = history.size(); if (limit > 0) { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java index 119b6615..2f4b9f43 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/context/ConversationWindowManager.java @@ -464,13 +464,19 @@ public class ConversationWindowManager { boundaryMetadata.put("tailKept", recentMessages.size()); boundaryMetadata.put("toolResultsSpilled", spillsThisTurn); boundaryMetadata.put("anchored", anchored); + Long summaryId = null; try { - conversationService.saveCompressionSummary( + 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 diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java index 8e57faae..2883ac5f 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/conversation/ConversationService.java @@ -387,6 +387,30 @@ public class ConversationService { .orderByAsc(MessageEntity::getId)); } + /** + * Returns the most recent compression boundary row for the conversation, + * or {@code null} if no boundary exists yet. Used by the agent loader to + * recover the structured summary when the boundary itself sits outside the + * recent-message window — without this, a long conversation that already + * compacted would feed the model the last N raw messages while silently + * dropping the goal / progress digest the boundary holds. + * + *
Implemented as a single indexed query rather than a full
+ * {@code listMessages} + filter so it stays cheap on conversations with
+ * thousands of messages. Selection: {@code role=system} +
+ * {@code metadata like '%compression_summary%'} (the metadata column always
+ * carries that literal — see {@link #saveCompressionSummary}).
+ */
+ public MessageEntity findLatestCompressionBoundary(String conversationId) {
+ return messageMapper.selectOne(new LambdaQueryWrapper Returns {@code null} when the insert path failed (logged at INFO);
+ * callers should treat that as "no boundary was persisted" and still
+ * broadcast a {@code done} event without {@code summaryId}.
+ */
+ public Long saveCompressionSummaryReturningId(String conversationId, String summary,
+ int compressedCount, Map