mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 11:58:34 +08:00
fix(context): preserve raw older tool results instead of rewriting them to a lossy summary
This commit is contained in:
parent
8f49b0ec92
commit
ae3d27922d
@ -14,6 +14,7 @@ import org.springframework.ai.chat.prompt.Prompt;
|
|||||||
import org.springframework.ai.tool.ToolCallback;
|
import org.springframework.ai.tool.ToolCallback;
|
||||||
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
|
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import vip.mate.agent.graph.executor.ToolResultStorage;
|
||||||
import vip.mate.agent.prompt.PromptLoader;
|
import vip.mate.agent.prompt.PromptLoader;
|
||||||
import vip.mate.config.ConversationWindowProperties;
|
import vip.mate.config.ConversationWindowProperties;
|
||||||
import vip.mate.memory.spi.MemoryManager;
|
import vip.mate.memory.spi.MemoryManager;
|
||||||
@ -72,7 +73,15 @@ public class ConversationWindowManager {
|
|||||||
private static final int CONTENT_MAX = 6000;
|
private static final int CONTENT_MAX = 6000;
|
||||||
private static final int CONTENT_HEAD = 4000;
|
private static final int CONTENT_HEAD = 4000;
|
||||||
private static final int CONTENT_TAIL = 1500;
|
private static final int CONTENT_TAIL = 1500;
|
||||||
private static final int OLD_TOOL_RESULT_SUMMARY_THRESHOLD = 500;
|
|
||||||
|
/**
|
||||||
|
* Minimum body size at which the duplicate-output placeholder is preferred
|
||||||
|
* over keeping the verbatim copy. Below this size the placeholder text
|
||||||
|
* (~80 chars) is comparable to the body itself, so deduplication only
|
||||||
|
* complicates the prompt without saving meaningful tokens. Above this
|
||||||
|
* size the dedup placeholder is a real win.
|
||||||
|
*/
|
||||||
|
private static final int DEDUP_MIN_CHARS = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tool names whose results must never be compacted into a one-line
|
* Tool names whose results must never be compacted into a one-line
|
||||||
@ -99,6 +108,20 @@ public class ConversationWindowManager {
|
|||||||
private final MemoryManager memoryManager;
|
private final MemoryManager memoryManager;
|
||||||
private final ConversationService conversationService;
|
private final ConversationService conversationService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional spill store, injected via setter so unit tests and the two
|
||||||
|
* existing 3-arg constructor callers in tests stay source-compatible.
|
||||||
|
* When {@code null}, prune falls back to "keep originals verbatim" — no
|
||||||
|
* lossy summary rewrite is ever applied. Spring autowires this when
|
||||||
|
* {@link ToolResultStorage} is on the context.
|
||||||
|
*/
|
||||||
|
private ToolResultStorage toolResultStorage;
|
||||||
|
|
||||||
|
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||||
|
public void setToolResultStorage(ToolResultStorage toolResultStorage) {
|
||||||
|
this.toolResultStorage = toolResultStorage;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 状态 ====================
|
// ==================== 状态 ====================
|
||||||
|
|
||||||
/** 摘要缓存:key = "conversationId:oldMessageCount" */
|
/** 摘要缓存:key = "conversationId:oldMessageCount" */
|
||||||
@ -133,7 +156,7 @@ public class ConversationWindowManager {
|
|||||||
Integer maxInputTokens, ChatModel chatModel,
|
Integer maxInputTokens, ChatModel chatModel,
|
||||||
String conversationId, Long agentId) {
|
String conversationId, Long agentId) {
|
||||||
return fitToWindow(messages, systemPrompt, currentUserMessage,
|
return fitToWindow(messages, systemPrompt, currentUserMessage,
|
||||||
maxInputTokens, chatModel, conversationId, agentId, null);
|
maxInputTokens, chatModel, conversationId, agentId, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -149,10 +172,31 @@ public class ConversationWindowManager {
|
|||||||
Integer maxInputTokens, ChatModel chatModel,
|
Integer maxInputTokens, ChatModel chatModel,
|
||||||
String conversationId, Long agentId,
|
String conversationId, Long agentId,
|
||||||
java.util.Collection<ToolCallback> toolCallbacks) {
|
java.util.Collection<ToolCallback> toolCallbacks) {
|
||||||
|
return fitToWindow(messages, systemPrompt, currentUserMessage,
|
||||||
|
maxInputTokens, chatModel, conversationId, agentId, toolCallbacks, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most comprehensive overload — adds {@code workspaceBasePath} so the
|
||||||
|
* pre-pass that prunes old tool results can route oversized bodies to
|
||||||
|
* the agent's workspace spill directory via {@link ToolResultStorage}.
|
||||||
|
*
|
||||||
|
* <p>When {@code workspaceBasePath} is {@code null}, spill files land in
|
||||||
|
* the configured base dir, or the JVM tmpdir as last resort (see
|
||||||
|
* {@link ToolResultStorage#resolveBaseDir(String)}). Workspace-aware
|
||||||
|
* callers should always pass the path so historical spill files stay
|
||||||
|
* grouped with the workspace that produced them.
|
||||||
|
*/
|
||||||
|
public List<Message> fitToWindow(List<Message> messages, String systemPrompt,
|
||||||
|
String currentUserMessage,
|
||||||
|
Integer maxInputTokens, ChatModel chatModel,
|
||||||
|
String conversationId, Long agentId,
|
||||||
|
java.util.Collection<ToolCallback> toolCallbacks,
|
||||||
|
String workspaceBasePath) {
|
||||||
if (messages == null || messages.isEmpty()) {
|
if (messages == null || messages.isEmpty()) {
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
messages = pruneOldToolResultsForModelInput(messages);
|
messages = pruneOldToolResultsForModelInput(messages, conversationId, workspaceBasePath);
|
||||||
|
|
||||||
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
|
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
|
||||||
? maxInputTokens : properties.getDefaultMaxInputTokens();
|
? maxInputTokens : properties.getDefaultMaxInputTokens();
|
||||||
@ -374,7 +418,54 @@ public class ConversationWindowManager {
|
|||||||
|
|
||||||
// ==================== 工具结果处理 ====================
|
// ==================== 工具结果处理 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backwards-compatible overload — older tool results that are oversized
|
||||||
|
* stay verbatim because no {@link ToolResultStorage} target is in
|
||||||
|
* scope. New call sites should use the 3-arg overload with explicit
|
||||||
|
* {@code conversationId} and {@code workspaceBasePath} so oversized
|
||||||
|
* bodies can be spilled to disk and recovered via {@code read_file}.
|
||||||
|
*/
|
||||||
public List<Message> pruneOldToolResultsForModelInput(List<Message> messages) {
|
public List<Message> pruneOldToolResultsForModelInput(List<Message> messages) {
|
||||||
|
return pruneOldToolResultsForModelInput(messages, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the messages newest-to-oldest, keeping the latest tool response
|
||||||
|
* verbatim and applying space-saving rewrites to older ones:
|
||||||
|
*
|
||||||
|
* <ol>
|
||||||
|
* <li>Bodies already starting with {@link ToolResultStorage#SPILL_MARKER_PREFIX}
|
||||||
|
* were spilled at tool-execution time — pass through untouched.</li>
|
||||||
|
* <li>If a body matches an identical body already seen in a newer turn,
|
||||||
|
* replace it with a short "duplicate tool output omitted" placeholder
|
||||||
|
* (only above {@link #DEDUP_MIN_CHARS} so we don't bloat tiny acks).</li>
|
||||||
|
* <li>Otherwise, when a {@link ToolResultStorage} is wired and a
|
||||||
|
* conversation id is available, try
|
||||||
|
* {@link ToolResultStorage#persistIfOversized} to spill the raw
|
||||||
|
* bytes to disk and replace the inline body with a preview + path
|
||||||
|
* so the model can read_file the original on demand.</li>
|
||||||
|
* <li>If none of the above apply, leave the body verbatim. Bodies
|
||||||
|
* under the spill threshold or running without a storage hook are
|
||||||
|
* preserved exactly — the lossy "summarized for model context"
|
||||||
|
* single-liner that used to fire here destroyed enough context
|
||||||
|
* on long tasks to be the wrong default.</li>
|
||||||
|
* </ol>
|
||||||
|
*
|
||||||
|
* <p>The {@link #PRUNE_EXEMPT_TOOLS} set still bypasses everything:
|
||||||
|
* sub-agent delegations are not replayable, so their full transcript
|
||||||
|
* stays in context.
|
||||||
|
*
|
||||||
|
* @param messages full conversation in chronological order
|
||||||
|
* @param conversationId used to scope spill files; {@code null} disables spill
|
||||||
|
* @param workspaceBasePath used to locate the spill directory; {@code null}
|
||||||
|
* falls back through the storage's resolveBaseDir chain
|
||||||
|
*/
|
||||||
|
public List<Message> pruneOldToolResultsForModelInput(List<Message> messages,
|
||||||
|
String conversationId,
|
||||||
|
String workspaceBasePath) {
|
||||||
|
if (messages == null || messages.isEmpty()) {
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
int latestToolResponseIndex = -1;
|
int latestToolResponseIndex = -1;
|
||||||
for (int i = messages.size() - 1; i >= 0; i--) {
|
for (int i = messages.size() - 1; i >= 0; i--) {
|
||||||
if (messages.get(i) instanceof ToolResponseMessage) {
|
if (messages.get(i) instanceof ToolResponseMessage) {
|
||||||
@ -386,9 +477,13 @@ public class ConversationWindowManager {
|
|||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean canSpill = toolResultStorage != null
|
||||||
|
&& conversationId != null && !conversationId.isEmpty();
|
||||||
|
|
||||||
List<Message> pruned = new ArrayList<>(messages);
|
List<Message> pruned = new ArrayList<>(messages);
|
||||||
java.util.Set<String> seenLargeOutputs = new java.util.HashSet<>();
|
java.util.Set<String> seenLargeOutputs = new java.util.HashSet<>();
|
||||||
int changed = 0;
|
int changed = 0;
|
||||||
|
int spilled = 0;
|
||||||
for (int i = pruned.size() - 1; i >= 0; i--) {
|
for (int i = pruned.size() - 1; i >= 0; i--) {
|
||||||
if (!(pruned.get(i) instanceof ToolResponseMessage trm)) {
|
if (!(pruned.get(i) instanceof ToolResponseMessage trm)) {
|
||||||
continue;
|
continue;
|
||||||
@ -398,23 +493,53 @@ public class ConversationWindowManager {
|
|||||||
boolean messageChanged = false;
|
boolean messageChanged = false;
|
||||||
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
|
||||||
String data = r.responseData();
|
String data = r.responseData();
|
||||||
boolean exempt = r.name() != null && PRUNE_EXEMPT_TOOLS.contains(r.name());
|
String name = r.name();
|
||||||
if (keepFull || exempt || data == null || data.length() <= OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
|
boolean exempt = name != null && PRUNE_EXEMPT_TOOLS.contains(name);
|
||||||
|
boolean alreadySpilled = data != null
|
||||||
|
&& data.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX);
|
||||||
|
|
||||||
|
// Pass through: the latest response, exempt tools, empty bodies,
|
||||||
|
// already-spilled previews — none should be rewritten.
|
||||||
|
if (keepFull || exempt || data == null || data.isEmpty() || alreadySpilled) {
|
||||||
newResponses.add(r);
|
newResponses.add(r);
|
||||||
if (data != null && data.length() > OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
|
if (data != null && data.length() > DEDUP_MIN_CHARS) {
|
||||||
seenLargeOutputs.add(data);
|
seenLargeOutputs.add(data);
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
String replacement;
|
|
||||||
if (seenLargeOutputs.contains(data)) {
|
// Dedup: identical body seen in a later turn already.
|
||||||
replacement = "[" + r.name() + "] duplicate tool output omitted; same content appeared later.";
|
if (data.length() > DEDUP_MIN_CHARS && seenLargeOutputs.contains(data)) {
|
||||||
} else {
|
String replacement = "[" + name
|
||||||
replacement = summarizeToolResponse(r.name(), data);
|
+ "] duplicate tool output omitted; same content appeared later.";
|
||||||
|
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), name, replacement));
|
||||||
|
messageChanged = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spill on demand: route oversized bodies to disk so the model
|
||||||
|
// can read_file them rather than losing them to a lossy summary.
|
||||||
|
if (canSpill) {
|
||||||
|
String candidate = toolResultStorage.persistIfOversized(
|
||||||
|
data, name, r.id(), conversationId, workspaceBasePath);
|
||||||
|
if (candidate != null
|
||||||
|
&& candidate.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX)) {
|
||||||
|
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), name, candidate));
|
||||||
|
seenLargeOutputs.add(data);
|
||||||
|
messageChanged = true;
|
||||||
|
spilled++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// returned unchanged: under threshold, excluded tool, or write failed.
|
||||||
|
// Fall through to "keep verbatim".
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: keep the body verbatim. Better to send a few extra
|
||||||
|
// tokens than to silently destroy data the model might need.
|
||||||
|
newResponses.add(r);
|
||||||
|
if (data.length() > DEDUP_MIN_CHARS) {
|
||||||
seenLargeOutputs.add(data);
|
seenLargeOutputs.add(data);
|
||||||
}
|
}
|
||||||
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), replacement));
|
|
||||||
messageChanged = true;
|
|
||||||
}
|
}
|
||||||
if (messageChanged) {
|
if (messageChanged) {
|
||||||
pruned.set(i, ToolResponseMessage.builder().responses(newResponses).build());
|
pruned.set(i, ToolResponseMessage.builder().responses(newResponses).build());
|
||||||
@ -422,37 +547,12 @@ public class ConversationWindowManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changed > 0) {
|
if (changed > 0) {
|
||||||
log.info("[ConversationWindow] Pruned {} older tool response message(s) before model request", changed);
|
log.info("[ConversationWindow] Pruned {} older tool response message(s) ({} spilled to disk) before model request",
|
||||||
|
changed, spilled);
|
||||||
}
|
}
|
||||||
return changed > 0 ? pruned : messages;
|
return changed > 0 ? pruned : messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String summarizeToolResponse(String toolName, String data) {
|
|
||||||
int chars = data.length();
|
|
||||||
int lines = data.isBlank() ? 0 : data.split("\\R", -1).length;
|
|
||||||
String firstLine = firstNonBlankLine(data);
|
|
||||||
if (firstLine.length() > 160) {
|
|
||||||
firstLine = firstLine.substring(0, 160) + "...";
|
|
||||||
}
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
sb.append('[').append(toolName).append("] previous tool output summarized for model context: ")
|
|
||||||
.append(chars).append(" chars, ").append(lines).append(" lines");
|
|
||||||
if (!firstLine.isBlank()) {
|
|
||||||
sb.append(". First line: ").append(firstLine);
|
|
||||||
}
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String firstNonBlankLine(String data) {
|
|
||||||
for (String line : data.split("\\R")) {
|
|
||||||
String trimmed = line.trim();
|
|
||||||
if (!trimmed.isBlank()) {
|
|
||||||
return trimmed.replace('|', '/');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Phase 1 - Soft trim:对工具结果做 head+tail 裁剪(保留首尾各 200 字符)。
|
* Phase 1 - Soft trim:对工具结果做 head+tail 裁剪(保留首尾各 200 字符)。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -439,7 +439,8 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
|
|||||||
chatModel,
|
chatModel,
|
||||||
conversationId,
|
conversationId,
|
||||||
parsedAgentId,
|
parsedAgentId,
|
||||||
toolSet != null ? toolSet.callbacks() : null);
|
toolSet != null ? toolSet.callbacks() : null,
|
||||||
|
workspaceBasePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Message> messages = new ArrayList<>(historyMessages);
|
List<Message> messages = new ArrayList<>(historyMessages);
|
||||||
|
|||||||
@ -348,7 +348,12 @@ public class ReasoningNode implements NodeAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (conversationWindowManager != null) {
|
if (conversationWindowManager != null) {
|
||||||
messages = conversationWindowManager.pruneOldToolResultsForModelInput(messages);
|
// Pass conversationId + workspaceBasePath so oversized older
|
||||||
|
// tool results can be spilled to the workspace spill directory
|
||||||
|
// (preserving the full body for read_file recovery) instead of
|
||||||
|
// being rewritten into a lossy single-line summary.
|
||||||
|
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
|
||||||
|
messages, conversationId, workspaceBasePath);
|
||||||
}
|
}
|
||||||
promptMessages.addAll(messages);
|
promptMessages.addAll(messages);
|
||||||
|
|
||||||
|
|||||||
@ -267,7 +267,8 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
|
|||||||
chatModel,
|
chatModel,
|
||||||
conversationId,
|
conversationId,
|
||||||
parsedAgentId,
|
parsedAgentId,
|
||||||
toolSet != null ? toolSet.callbacks() : null);
|
toolSet != null ? toolSet.callbacks() : null,
|
||||||
|
workspaceBasePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Message> messages = new ArrayList<>(historyMessages);
|
List<Message> messages = new ArrayList<>(historyMessages);
|
||||||
|
|||||||
@ -211,7 +211,11 @@ public class StepExecutionNode implements NodeAction {
|
|||||||
ChatOptions options = oaiOpts;
|
ChatOptions options = oaiOpts;
|
||||||
|
|
||||||
if (conversationWindowManager != null) {
|
if (conversationWindowManager != null) {
|
||||||
messages = conversationWindowManager.pruneOldToolResultsForModelInput(messages);
|
// Pass conversationId + workspaceBasePath so oversized
|
||||||
|
// older tool results can be spilled to disk instead of
|
||||||
|
// being rewritten into a lossy single-line summary.
|
||||||
|
messages = conversationWindowManager.pruneOldToolResultsForModelInput(
|
||||||
|
messages, conversationId, workspaceBasePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
|
NodeStreamingChatHelper.StreamResult result = streamingHelper.streamCall(
|
||||||
|
|||||||
@ -1,23 +1,51 @@
|
|||||||
package vip.mate.agent.context;
|
package vip.mate.agent.context;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
import org.springframework.ai.chat.messages.Message;
|
import org.springframework.ai.chat.messages.Message;
|
||||||
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
import org.springframework.ai.chat.messages.ToolResponseMessage;
|
||||||
import org.springframework.ai.chat.messages.UserMessage;
|
import org.springframework.ai.chat.messages.UserMessage;
|
||||||
|
import vip.mate.agent.graph.executor.ToolResultProperties;
|
||||||
|
import vip.mate.agent.graph.executor.ToolResultStorage;
|
||||||
import vip.mate.config.ConversationWindowProperties;
|
import vip.mate.config.ConversationWindowProperties;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavior of {@link ConversationWindowManager#pruneOldToolResultsForModelInput} —
|
||||||
|
* the pre-pass that runs before every model request to keep old tool results
|
||||||
|
* from inflating the prompt.
|
||||||
|
*
|
||||||
|
* <p>The current contract:
|
||||||
|
* <ul>
|
||||||
|
* <li>The latest tool response is kept verbatim.</li>
|
||||||
|
* <li>Older bodies under the dedup threshold are kept verbatim.</li>
|
||||||
|
* <li>Older bodies that are byte-identical to a newer body are replaced
|
||||||
|
* with a short "duplicate omitted" placeholder.</li>
|
||||||
|
* <li>Older bodies above {@link ToolResultStorage}'s spill threshold are
|
||||||
|
* written to disk; the in-prompt body becomes a preview + path so the
|
||||||
|
* model can call {@code read_file} for the full content.</li>
|
||||||
|
* <li>Without storage wired, old bodies stay verbatim. The previous
|
||||||
|
* behaviour — rewriting them into a lossy single-line summary —
|
||||||
|
* destroyed too much context on long tasks and was removed.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
class ConversationWindowManagerToolPruningTest {
|
class ConversationWindowManagerToolPruningTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void prunesOlderToolResultsAndKeepsLatestFullResult() {
|
void withoutStorageOlderLargeBodiesStayVerbatim() {
|
||||||
ConversationWindowManager manager = new ConversationWindowManager(
|
ConversationWindowManager manager = new ConversationWindowManager(
|
||||||
new ConversationWindowProperties(), null, null);
|
new ConversationWindowProperties(), null, null);
|
||||||
String oldLarge = "old-result\n".repeat(700);
|
String oldLarge = "old-result\n".repeat(700); // ~7700 chars
|
||||||
String latestLarge = "latest-result\n".repeat(700);
|
String latestLarge = "latest-result\n".repeat(700);
|
||||||
List<Message> messages = List.of(
|
List<Message> messages = List.of(
|
||||||
new UserMessage("read earlier file"),
|
new UserMessage("read earlier file"),
|
||||||
@ -33,13 +61,15 @@ class ConversationWindowManagerToolPruningTest {
|
|||||||
String oldData = oldToolMessage.getResponses().getFirst().responseData();
|
String oldData = oldToolMessage.getResponses().getFirst().responseData();
|
||||||
String latestData = latestToolMessage.getResponses().getFirst().responseData();
|
String latestData = latestToolMessage.getResponses().getFirst().responseData();
|
||||||
|
|
||||||
assertTrue(oldData.contains("previous tool output summarized"));
|
// No storage → keep the old body untouched, do NOT collapse to a lossy summary.
|
||||||
assertTrue(oldData.length() < 300);
|
assertEquals(oldLarge, oldData,
|
||||||
|
"without storage, older tool bodies must be preserved verbatim "
|
||||||
|
+ "(the lossy single-line rewrite has been removed)");
|
||||||
assertEquals(latestLarge, latestData);
|
assertEquals(latestLarge, latestData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void olderDuplicateToolResultUsesDuplicatePlaceholder() {
|
void olderDuplicateToolResultStillUsesDuplicatePlaceholder(@TempDir Path tempDir) {
|
||||||
ConversationWindowManager manager = new ConversationWindowManager(
|
ConversationWindowManager manager = new ConversationWindowManager(
|
||||||
new ConversationWindowProperties(), null, null);
|
new ConversationWindowProperties(), null, null);
|
||||||
String repeated = "same-output\n".repeat(700);
|
String repeated = "same-output\n".repeat(700);
|
||||||
@ -52,7 +82,149 @@ class ConversationWindowManagerToolPruningTest {
|
|||||||
|
|
||||||
ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.getFirst();
|
ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.getFirst();
|
||||||
String oldData = oldToolMessage.getResponses().getFirst().responseData();
|
String oldData = oldToolMessage.getResponses().getFirst().responseData();
|
||||||
assertTrue(oldData.contains("duplicate tool output omitted"));
|
assertTrue(oldData.contains("duplicate tool output omitted"),
|
||||||
|
"byte-identical duplicates older than the latest copy still get the dedup placeholder");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withStorageOlderLargeBodiesGetSpilledToDisk(@TempDir Path tempDir) throws Exception {
|
||||||
|
ConversationWindowManager manager = newManagerWithStorage(tempDir, /*threshold*/ 2000);
|
||||||
|
|
||||||
|
String oldLarge = "alpha\n".repeat(800); // 4800 chars > threshold 2000
|
||||||
|
String latestLarge = "beta\n".repeat(800);
|
||||||
|
List<Message> messages = List.of(
|
||||||
|
new UserMessage("turn 1"),
|
||||||
|
toolMessage("old-1", "web_search", oldLarge),
|
||||||
|
new UserMessage("turn 2"),
|
||||||
|
toolMessage("new-1", "web_search", latestLarge)
|
||||||
|
);
|
||||||
|
|
||||||
|
List<Message> pruned = manager.pruneOldToolResultsForModelInput(
|
||||||
|
messages, "conv-A", tempDir.toString());
|
||||||
|
|
||||||
|
ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.get(1);
|
||||||
|
ToolResponseMessage latestToolMessage = (ToolResponseMessage) pruned.get(3);
|
||||||
|
String oldData = oldToolMessage.getResponses().getFirst().responseData();
|
||||||
|
String latestData = latestToolMessage.getResponses().getFirst().responseData();
|
||||||
|
|
||||||
|
assertTrue(oldData.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX),
|
||||||
|
"older oversized body should be spilled and replaced with a SPILL_MARKER preview");
|
||||||
|
assertNotEquals(oldLarge, oldData, "old data should be replaced");
|
||||||
|
// Latest one is always kept full regardless of size.
|
||||||
|
assertEquals(latestLarge, latestData);
|
||||||
|
|
||||||
|
// Verify the spill file contains the FULL raw body, not a truncated version.
|
||||||
|
Matcher m = Pattern.compile("path=(\\S+)").matcher(oldData);
|
||||||
|
assertTrue(m.find(), "preview must report the spill path");
|
||||||
|
Path spillFile = Path.of(m.group(1));
|
||||||
|
assertTrue(Files.exists(spillFile));
|
||||||
|
assertEquals(oldLarge, Files.readString(spillFile),
|
||||||
|
"spill file must hold the full original body — the whole point of preserving "
|
||||||
|
+ "raw output for read_file recovery");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void withStorageOlderSmallBodiesStayVerbatim(@TempDir Path tempDir) {
|
||||||
|
ConversationWindowManager manager = newManagerWithStorage(tempDir, /*threshold*/ 2000);
|
||||||
|
|
||||||
|
String oldSmall = "small old body"; // far under threshold
|
||||||
|
String latestLarge = "x".repeat(3000);
|
||||||
|
List<Message> messages = List.of(
|
||||||
|
toolMessage("old-1", "web_search", oldSmall),
|
||||||
|
new UserMessage("turn"),
|
||||||
|
toolMessage("new-1", "web_search", latestLarge)
|
||||||
|
);
|
||||||
|
|
||||||
|
List<Message> pruned = manager.pruneOldToolResultsForModelInput(
|
||||||
|
messages, "conv-B", tempDir.toString());
|
||||||
|
|
||||||
|
ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.get(0);
|
||||||
|
String oldData = oldToolMessage.getResponses().getFirst().responseData();
|
||||||
|
assertEquals(oldSmall, oldData,
|
||||||
|
"bodies under the spill threshold stay verbatim — small results carry no compression win");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void alreadySpilledMarkerIsNotReSpilled(@TempDir Path tempDir) {
|
||||||
|
ConversationWindowManager manager = newManagerWithStorage(tempDir, /*threshold*/ 1000);
|
||||||
|
|
||||||
|
// Simulate a body that was spilled at tool-execution time: it already
|
||||||
|
// starts with the spill marker. Prune must leave it alone instead of
|
||||||
|
// trying to spill a spill preview (which would write the preview text
|
||||||
|
// to a new file, ad infinitum).
|
||||||
|
String alreadySpilled = ToolResultStorage.SPILL_MARKER_PREFIX
|
||||||
|
+ " tool=web_search full_chars=22000 path=/tmp/x.txt\n[Preview ...]\nbody preview ...";
|
||||||
|
String latestLarge = "y".repeat(3000);
|
||||||
|
List<Message> messages = List.of(
|
||||||
|
toolMessage("old-1", "web_search", alreadySpilled),
|
||||||
|
toolMessage("new-1", "web_search", latestLarge)
|
||||||
|
);
|
||||||
|
|
||||||
|
List<Message> pruned = manager.pruneOldToolResultsForModelInput(
|
||||||
|
messages, "conv-C", tempDir.toString());
|
||||||
|
|
||||||
|
ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.get(0);
|
||||||
|
String oldData = oldToolMessage.getResponses().getFirst().responseData();
|
||||||
|
assertEquals(alreadySpilled, oldData,
|
||||||
|
"previously-spilled previews must pass through untouched — no double-spill");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exemptToolStaysVerbatimEvenWhenOversized(@TempDir Path tempDir) {
|
||||||
|
ConversationWindowManager manager = newManagerWithStorage(tempDir, /*threshold*/ 1000);
|
||||||
|
|
||||||
|
String oldLarge = "z".repeat(5000);
|
||||||
|
String latestLarge = "z".repeat(5000);
|
||||||
|
List<Message> messages = List.of(
|
||||||
|
toolMessage("old-1", "delegateToAgent", oldLarge), // exempt tool
|
||||||
|
toolMessage("new-1", "delegateToAgent", latestLarge)
|
||||||
|
);
|
||||||
|
|
||||||
|
List<Message> pruned = manager.pruneOldToolResultsForModelInput(
|
||||||
|
messages, "conv-D", tempDir.toString());
|
||||||
|
|
||||||
|
ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.get(0);
|
||||||
|
String oldData = oldToolMessage.getResponses().getFirst().responseData();
|
||||||
|
assertEquals(oldLarge, oldData,
|
||||||
|
"sub-agent delegation results are irreplaceable — must never be rewritten");
|
||||||
|
assertFalse(oldData.startsWith(ToolResultStorage.SPILL_MARKER_PREFIX),
|
||||||
|
"exempt tools should also not be spilled (they're already cheap to keep)");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blankConversationIdDisablesSpill(@TempDir Path tempDir) {
|
||||||
|
ConversationWindowManager manager = newManagerWithStorage(tempDir, /*threshold*/ 1000);
|
||||||
|
|
||||||
|
String oldLarge = "q".repeat(5000);
|
||||||
|
String latestLarge = "r".repeat(5000);
|
||||||
|
List<Message> messages = List.of(
|
||||||
|
toolMessage("old-1", "web_search", oldLarge),
|
||||||
|
toolMessage("new-1", "web_search", latestLarge)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Without a conversationId, spill cannot scope files safely → falls back to verbatim.
|
||||||
|
List<Message> pruned = manager.pruneOldToolResultsForModelInput(
|
||||||
|
messages, null, tempDir.toString());
|
||||||
|
|
||||||
|
ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.get(0);
|
||||||
|
String oldData = oldToolMessage.getResponses().getFirst().responseData();
|
||||||
|
assertEquals(oldLarge, oldData,
|
||||||
|
"null conversationId must not trigger spill — caller cannot scope files correctly");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ helpers
|
||||||
|
|
||||||
|
private static ConversationWindowManager newManagerWithStorage(Path tempDir, int threshold) {
|
||||||
|
ToolResultProperties props = new ToolResultProperties();
|
||||||
|
props.setStorageBaseDir(tempDir.toString());
|
||||||
|
props.setPerResultThresholdChars(threshold);
|
||||||
|
props.setPreviewHeadChars(120);
|
||||||
|
ToolResultStorage storage = new ToolResultStorage(props);
|
||||||
|
|
||||||
|
ConversationWindowManager manager = new ConversationWindowManager(
|
||||||
|
new ConversationWindowProperties(), null, null);
|
||||||
|
manager.setToolResultStorage(storage);
|
||||||
|
return manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ToolResponseMessage toolMessage(String id, String name, String data) {
|
private static ToolResponseMessage toolMessage(String id, String name, String data) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user