feat(context): preserve spill markers across compaction phases, persist enriched boundary, broadcast compact_status SSE (#110)

This commit is contained in:
matevip 2026-05-13 08:50:28 +08:00
parent 86a6829102
commit 51b5eceb7d
6 changed files with 397 additions and 40 deletions

View File

@ -22,6 +22,7 @@ import vip.mate.workspace.conversation.ConversationService;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
@ -131,6 +132,21 @@ public class ConversationWindowManager {
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" */
@ -205,6 +221,8 @@ public class ConversationWindowManager {
if (messages == null || messages.isEmpty()) {
return messages;
}
long spillsAtEntry = (toolResultStorage != null) ? toolResultStorage.getSpillCount() : 0L;
messages = pruneOldToolResultsForModelInput(messages, conversationId, workspaceBasePath);
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
@ -229,7 +247,7 @@ public class ConversationWindowManager {
// 可用于历史的 token 预算 = max - system - currentMsg - tools - 安全余量
int reservedTokens = systemTokens + currentMsgTokens + toolsTokens + (int) (effectiveMax * 0.05);
// RFC-025 Change 1: reserve 硬封顶到 effectiveMax 50%
// 预留 reserve 硬封顶到 effectiveMax 50%
// 小上下文模型Ollama 16K本地 8KsystemTokens + currentMsgTokens 很容易
// 接近或超过 effectiveMax不封顶会让 historyBudget 变负数导致死循环压缩
// 压缩目标比压缩前还大 压缩后又触发压缩
@ -244,7 +262,8 @@ public class ConversationWindowManager {
// 尾部保护 token 预算阈值的 20% Hermes 一致
int tailTokenBudget = (int) (triggerThreshold * 0.20);
return compactMessages(messages, historyBudget, tailTokenBudget, chatModel, conversationId, agentId);
return compactMessages(messages, historyBudget, tailTokenBudget, chatModel,
conversationId, agentId, totalTokens, spillsAtEntry);
}
/**
@ -260,15 +279,40 @@ public class ConversationWindowManager {
// ==================== 核心压缩逻辑 ====================
/** 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) {
String conversationId, Long agentId,
int preTokens, long spillsAtEntry) {
broadcastCompactStatus(conversationId, "start", Map.of(
"preTokens", preTokens,
"messagesIn", messages.size(),
"trigger", "token_threshold"
));
// 动态计算尾部保护边界替代固定 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;
}
@ -281,8 +325,14 @@ public class ConversationWindowManager {
// 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));
@ -340,13 +390,20 @@ public class ConversationWindowManager {
// 计算动态摘要预算
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);
@ -355,21 +412,12 @@ public class ConversationWindowManager {
int count = compressionCounts.merge(conversationId, 1, Integer::sum);
log.info("[ConversationWindow] 生成结构化摘要 ({} 字符, 第 {} 次压缩), 压缩 {} 条旧消息, conv={}",
summary.length(), count, oldMessages.size(), conversationId);
// 持久化摘要到 DB下次加载历史时可直接从摘要位置开始跳过重复压缩
if (conversationService != null) {
try {
conversationService.saveCompressionSummary(
conversationId, SUMMARY_PREFIX + summary, oldMessages.size());
} catch (Exception e) {
log.warn("[ConversationWindow] Failed to persist compression summary: {}", e.getMessage());
}
}
}
}
// 组装结果
List<Message> result = new ArrayList<>();
boolean anchored = false;
if (summary != null && !summary.isBlank()) {
result.add(new UserMessage(SUMMARY_PREFIX + summary));
@ -380,11 +428,16 @@ public class ConversationWindowManager {
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);
@ -393,6 +446,42 @@ public class ConversationWindowManager {
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", "token_threshold");
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);
try {
conversationService.saveCompressionSummary(
conversationId, SUMMARY_PREFIX + summary, oldMessages.size(),
boundaryMetadata);
} catch (Exception e) {
log.warn("[ConversationWindow] Failed to persist compression boundary: {}", e.getMessage());
}
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;
@ -780,15 +869,36 @@ public class ConversationWindowManager {
}
/**
* Phase 1 - Soft trim对工具结果做 head+tail 裁剪保留首尾各 200 字符
* 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.
*/
private int softTrimToolResults(List<Message> messages) {
static boolean isSpillMarker(ToolResponseMessage.ToolResponse r) {
return r != null
&& r.responseData() != null
&& r.responseData().startsWith(ToolResultStorage.SPILL_MARKER_PREFIX);
}
/**
* 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;
}
String data = r.responseData();
if (data != null && data.length() > 500) {
String head = data.substring(0, 200);
@ -811,16 +921,28 @@ public class ConversationWindowManager {
/**
* 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}.
*/
private int hardClearToolResults(List<Message> messages) {
int hardClearToolResults(List<Message> messages) {
int cleared = 0;
for (int i = 0; i < messages.size(); i++) {
if (messages.get(i) instanceof ToolResponseMessage trm) {
List<ToolResponseMessage.ToolResponse> placeholders = trm.getResponses().stream()
.map(r -> new ToolResponseMessage.ToolResponse(r.id(), r.name(), "[tool result removed]"))
.toList();
messages.set(i, ToolResponseMessage.builder().responses(placeholders).build());
cleared++;
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;
}
replaced.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), "[tool result removed]"));
changed = true;
}
if (changed) {
messages.set(i, ToolResponseMessage.builder().responses(replaced).build());
cleared++;
}
}
}
return cleared;
@ -828,18 +950,27 @@ public class ConversationWindowManager {
/**
* 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.
*/
private int prePruneForSummary(List<Message> messages) {
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);
.anyMatch(r -> !isSpillMarker(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();
List<ToolResponseMessage.ToolResponse> placeholders = new ArrayList<>(trm.getResponses().size());
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
if (isSpillMarker(r)) {
placeholders.add(r);
continue;
}
placeholders.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(),
"[旧工具输出已清理以节省上下文空间]"));
}
messages.set(i, ToolResponseMessage.builder().responses(placeholders).build());
pruned++;
}

View File

@ -99,12 +99,17 @@ public class ToolResultProperties {
/**
* Days to retain spill files before the scheduled cleanup deletes them.
* Spill files exist to let the model recover full tool output via
* {@code read_file} during the active conversation; once the conversation
* is dormant for this many days the agent is extremely unlikely to ever
* read the file again, and disk pressure starts to matter.
* <p><b>Default 0 means time-based cleanup is disabled</b> spill files
* stay on disk until the owning conversation is explicitly deleted (which
* fires {@code purgeConversation} via {@code ConversationService}).
* This preserves the "recoverable" invariant: a summary or preview that
* cites a spill path will keep working for the whole life of the
* conversation, no matter how long it sits dormant.
* <p>Set to a positive value if disk pressure outweighs recoverability
* for your deployment. The scheduled sweep will then delete files whose
* mtime falls outside the retention horizon.
*/
private int retentionDays = 7;
private int retentionDays = 0;
/**
* Cron expression for the spill-cleanup task. Defaults to once a day at

View File

@ -428,18 +428,65 @@ public class ConversationService {
}
/**
* 将压缩摘要持久化为 role=system 的特殊消息
* 下次加载历史时识别此消息跳过它之前的已压缩消息
* Persist a compaction boundary as a role=system message. The body is
* the summary text; the metadata describes <em>what happened</em> at
* this boundary (trigger, pre/post tokens, how many messages were
* summarised, how many spill files were produced, how many tail
* messages survived). On the next load this row is the cut-off older
* messages are skipped, the model picks up from the summary forward.
*
* <p>Backward-compat overload: legacy callers that only know the row
* count still work and produce a minimal metadata block.
*/
public void saveCompressionSummary(String conversationId, String summary, int compressedCount) {
saveCompressionSummary(conversationId, summary, compressedCount, Map.of());
}
/**
* Same as the 3-arg overload but accepts extra structured fields that
* are merged into the boundary's metadata JSON. Fields the frontend
* and observability pipeline care about:
* <ul>
* <li>{@code trigger} what fired this boundary
* ({@code token_threshold}, {@code user_compact}, etc.)</li>
* <li>{@code preTokens} / {@code postTokens} context size before
* and after, for the in-prompt status row</li>
* <li>{@code messagesSummarized} / {@code tailKept} partition
* counts the user sees in the boundary card</li>
* <li>{@code toolResultsSpilled} how many bodies the spill store
* absorbed during this boundary</li>
* <li>{@code summaryId} stable id (the inserted message id) for
* deep-linking from the SSE event</li>
* </ul>
* <p>{@code type=compression_summary} is always present the loader
* keys off it. {@code compressedCount} is kept for backward compat.
*/
public void saveCompressionSummary(String conversationId, String summary, int compressedCount,
Map<String, Object> extraMetadata) {
MessageEntity entity = new MessageEntity();
entity.setConversationId(conversationId);
entity.setRole("system");
entity.setContent(summary);
entity.setStatus("completed");
entity.setMetadata("{\"type\":\"compression_summary\",\"compressedCount\":" + compressedCount + "}");
Map<String, Object> metadata = new java.util.LinkedHashMap<>();
metadata.put("type", "compression_summary");
metadata.put("compressedCount", compressedCount);
if (extraMetadata != null) {
extraMetadata.forEach((k, v) -> {
if (v != null) metadata.put(k, v);
});
}
try {
entity.setMetadata(objectMapper.writeValueAsString(metadata));
} catch (com.fasterxml.jackson.core.JsonProcessingException e) {
log.warn("[Conversation] Failed to serialise compaction metadata, falling back to minimal: {}",
e.getMessage());
entity.setMetadata("{\"type\":\"compression_summary\",\"compressedCount\":" + compressedCount + "}");
}
messageMapper.insert(entity);
log.info("[Conversation] Saved compression summary for conv={}, compressedCount={}", conversationId, compressedCount);
log.info("[Conversation] Saved compression boundary conv={}, compressedCount={}, metadata={}",
conversationId, compressedCount, entity.getMetadata());
}
public List<MessageVO> listMessageViews(String conversationId) {

View File

@ -229,10 +229,13 @@ mate:
excluded-tools:
- read_file
- read_workspace_memory_file
# Spill files are deleted after this many days. Set to 0 to disable the
# scheduled sweep entirely (files still get purged when the conversation
# is deleted explicitly via ConversationService.deleteConversation).
retention-days: 7
# Spill files are deleted after this many days. Default 0 disables the
# scheduled sweep entirely so a summary/preview that points at a spill
# path stays valid for the whole life of the conversation. Files are
# still purged when the conversation is deleted explicitly via
# ConversationService.deleteConversation. Raise to a positive value if
# disk pressure outweighs recoverability for your deployment.
retention-days: 0
cleanup-cron: "0 0 3 * * ?"
conversation:
window:

View File

@ -0,0 +1,157 @@
package vip.mate.agent.context;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import vip.mate.agent.graph.executor.ToolResultStorage;
import vip.mate.config.ConversationWindowProperties;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* The three compaction phases (soft trim, hard clear, pre-prune for
* summary) must never destroy a spill-marker body doing so would erase
* the {@code path=...} pointer the model needs to recover the original
* full output via {@code read_file}, which is the whole reason that body
* was spilled in the first place.
*
* <p>This is the "recoverable" invariant: once a tool output makes it
* into the spill store, the in-context representation stays a stable
* preview + path for the rest of the conversation regardless of how
* aggressively the window manager has to compress the prefix.
*/
class ConversationWindowManagerSpillMarkerPreservationTest {
private static final String SPILL_BODY = ToolResultStorage.SPILL_MARKER_PREFIX
+ " tool=web_search full_chars=22000 path=/tmp/x.txt\n"
+ "[Preview — first 800 of 22000 chars. Use read_file with the path above to retrieve the rest.]\n"
+ "preview body fragment that contributes most of the inline size...";
@Test
void softTrimLeavesSpillMarkerUntouched() {
ConversationWindowManager mgr = new ConversationWindowManager(
new ConversationWindowProperties(), null, null);
List<Message> messages = new ArrayList<>(List.of(
toolMessage("call-spill", "web_search", SPILL_BODY),
toolMessage("call-big", "search", "x".repeat(2000))
));
// Pass the same list through Phase 1.
int trimmed = mgr.softTrimToolResults(messages);
// The non-spill body must have been trimmed (it was > 500 chars).
// The spill body must remain identical to the original.
ToolResponseMessage trm0 = (ToolResponseMessage) messages.get(0);
ToolResponseMessage trm1 = (ToolResponseMessage) messages.get(1);
assertEquals(SPILL_BODY, trm0.getResponses().getFirst().responseData(),
"Phase 1 soft trim must not modify a spill-marker body");
assertTrue(trm1.getResponses().getFirst().responseData().contains("[trimmed "),
"non-spill bodies should still be trimmed by Phase 1");
assertEquals(1, trimmed,
"trim counter should reflect only the non-spill body that was actually shortened");
}
@Test
void hardClearLeavesSpillMarkerUntouched() {
ConversationWindowManager mgr = new ConversationWindowManager(
new ConversationWindowProperties(), null, null);
List<Message> messages = new ArrayList<>(List.of(
toolMessage("call-spill", "web_search", SPILL_BODY),
toolMessage("call-big", "search", "y".repeat(2000))
));
int cleared = mgr.hardClearToolResults(messages);
ToolResponseMessage trm0 = (ToolResponseMessage) messages.get(0);
ToolResponseMessage trm1 = (ToolResponseMessage) messages.get(1);
assertEquals(SPILL_BODY, trm0.getResponses().getFirst().responseData(),
"Phase 2 hard clear must not replace a spill-marker body with [tool result removed]");
assertEquals("[tool result removed]", trm1.getResponses().getFirst().responseData(),
"non-spill bodies should still be replaced by Phase 2");
assertEquals(1, cleared,
"clear counter should reflect only the non-spill body that was actually replaced");
}
@Test
void prePruneForSummaryLeavesSpillMarkerUntouched() {
ConversationWindowManager mgr = new ConversationWindowManager(
new ConversationWindowProperties(), null, null);
List<Message> messages = new ArrayList<>(List.of(
toolMessage("call-spill", "web_search", SPILL_BODY),
toolMessage("call-big", "search", "z".repeat(2000))
));
int pruned = mgr.prePruneForSummary(messages);
ToolResponseMessage trm0 = (ToolResponseMessage) messages.get(0);
ToolResponseMessage trm1 = (ToolResponseMessage) messages.get(1);
assertEquals(SPILL_BODY, trm0.getResponses().getFirst().responseData(),
"Phase 3 pre-prune must not replace a spill-marker body with the cleared-output placeholder");
assertTrue(trm1.getResponses().getFirst().responseData().contains("旧工具输出已清理"),
"non-spill bodies should still be replaced by Phase 3");
assertEquals(1, pruned);
}
@Test
void mixedMessageWithSpillAndNonSpillResponsesPreservesOnlyTheMarker() {
// A single ToolResponseMessage can hold multiple ToolResponses (one
// assistant tool_calls turn could ask for several tools at once).
// The phase guards must operate at the response level, not the
// message level the spill response stays, the non-spill response
// gets the placeholder.
ConversationWindowManager mgr = new ConversationWindowManager(
new ConversationWindowProperties(), null, null);
ToolResponseMessage mixed = ToolResponseMessage.builder().responses(List.of(
new ToolResponseMessage.ToolResponse("call-spill", "web_search", SPILL_BODY),
new ToolResponseMessage.ToolResponse("call-big", "search", "q".repeat(2000))
)).build();
List<Message> messages = new ArrayList<>(List.of(mixed));
mgr.hardClearToolResults(messages);
ToolResponseMessage trm = (ToolResponseMessage) messages.getFirst();
assertEquals(SPILL_BODY, trm.getResponses().get(0).responseData(),
"the spill response in a mixed message must survive Phase 2");
assertEquals("[tool result removed]", trm.getResponses().get(1).responseData(),
"the non-spill response in a mixed message must still be cleared");
}
@Test
void smallSpillMarkerStillStaysVerbatim() {
// Edge case: even when the preview is short (under the 500-char
// soft-trim threshold), the marker check should still apply. This
// protects against future changes to the trim threshold.
ConversationWindowManager mgr = new ConversationWindowManager(
new ConversationWindowProperties(), null, null);
String tinySpill = ToolResultStorage.SPILL_MARKER_PREFIX
+ " tool=test full_chars=600 path=/tmp/t.txt\n[tiny]";
List<Message> messages = new ArrayList<>(List.of(
toolMessage("call-1", "test", tinySpill)
));
mgr.softTrimToolResults(messages);
mgr.hardClearToolResults(messages);
mgr.prePruneForSummary(messages);
ToolResponseMessage trm = (ToolResponseMessage) messages.getFirst();
assertEquals(tinySpill, trm.getResponses().getFirst().responseData(),
"the marker check is what protects the body — not the size of the preview");
}
// ------------------------------------------------------------------ helpers
private static ToolResponseMessage toolMessage(String id, String name, String data) {
return ToolResponseMessage.builder()
.responses(List.of(new ToolResponseMessage.ToolResponse(id, name, data)))
.build();
}
}

View File

@ -103,6 +103,20 @@ class LaneDExecutorAndConfigTest {
"storageBaseDir should still default to empty string");
}
@Test
@DisplayName("retentionDays defaults to 0 so spill files outlive their conversation")
void retentionDaysDefaultsToZero() {
// The recoverability invariant: a summary or preview that cites
// a spill path must keep working for the whole life of the
// conversation. Time-based deletion is opt-in; operators with
// disk pressure can raise this value explicitly.
ToolResultProperties props = new ToolResultProperties();
assertEquals(0, props.getRetentionDays(),
"retentionDays must default to 0 — time-based purge is opt-in to preserve recoverability");
assertTrue(props.getCleanupCron() != null && !props.getCleanupCron().isBlank(),
"cleanupCron stays defined; it is a no-op while retentionDays=0");
}
@Test
@DisplayName("Per-result threshold matches the executor inline hard cap so spill and truncate share one ladder")
void thresholdMatchesExecutorHardCap() throws Exception {