fix(context): keep the latest summary even when its row sits outside the recent window; include summaryId in compact_status

This commit is contained in:
matevip 2026-05-13 08:50:42 +08:00
parent faf7f98358
commit af56763156
3 changed files with 79 additions and 1 deletions

View File

@ -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) {

View File

@ -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

View File

@ -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.
*
* <p>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<MessageEntity>()
.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.
*
* <p>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<String, Object> 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<String, Object> extraMetadata) {
saveCompressionSummaryInternal(conversationId, summary, compressedCount, extraMetadata);
}
private Long saveCompressionSummaryInternal(String conversationId, String summary, int compressedCount,
Map<String, Object> 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<MessageVO> listMessageViews(String conversationId) {