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() + .eq(MessageEntity::getConversationId, conversationId) + .eq(MessageEntity::getRole, "system") + .like(MessageEntity::getMetadata, "compression_summary") + .orderByDesc(MessageEntity::getCreateTime) + .orderByDesc(MessageEntity::getId) + .last("LIMIT 1")); + } + /** * 加载最近 N 条消息(倒序取出后翻转为正序)。 * 利用复合索引 (conversation_id, create_time) 高效分页。 @@ -442,6 +466,24 @@ public class ConversationService { saveCompressionSummary(conversationId, summary, compressedCount, Map.of()); } + /** + * Same as {@link #saveCompressionSummary(String, String, int, Map)} but + * returns the inserted row's id so callers (notably + * {@code ConversationWindowManager}) can include the {@code summaryId} + * in the {@code compact_status} SSE payload. The id is also written back + * into the row's metadata JSON by the underlying overload, so the row is + * still self-describing if a client misses the SSE event and loads + * history later. + * + *

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 extraMetadata) { + return saveCompressionSummaryInternal(conversationId, summary, compressedCount, extraMetadata); + } + /** * Same as the 3-arg overload but accepts extra structured fields that * are merged into the boundary's metadata JSON. Fields the frontend @@ -463,6 +505,11 @@ public class ConversationService { */ public void saveCompressionSummary(String conversationId, String summary, int compressedCount, Map extraMetadata) { + saveCompressionSummaryInternal(conversationId, summary, compressedCount, extraMetadata); + } + + private Long saveCompressionSummaryInternal(String conversationId, String summary, int compressedCount, + Map extraMetadata) { MessageEntity entity = new MessageEntity(); entity.setConversationId(conversationId); entity.setRole("system"); @@ -506,6 +553,7 @@ public class ConversationService { } log.info("[Conversation] Saved compression boundary conv={}, compressedCount={}, metadata={}", conversationId, compressedCount, entity.getMetadata()); + return entity.getId(); } public List listMessageViews(String conversationId) {