fix(context): preserve raw older tool results instead of rewriting them to a lossy summary

This commit is contained in:
matevip 2026-05-13 08:50:02 +08:00
parent 8f49b0ec92
commit ae3d27922d
6 changed files with 333 additions and 50 deletions

View File

@ -14,6 +14,7 @@ import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.tool.ToolCallback;
import com.alibaba.cloud.ai.dashscope.chat.DashScopeChatOptions;
import org.springframework.stereotype.Component;
import vip.mate.agent.graph.executor.ToolResultStorage;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.config.ConversationWindowProperties;
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_HEAD = 4000;
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
@ -99,6 +108,20 @@ public class ConversationWindowManager {
private final MemoryManager memoryManager;
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" */
@ -133,7 +156,7 @@ public class ConversationWindowManager {
Integer maxInputTokens, ChatModel chatModel,
String conversationId, Long agentId) {
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,
String conversationId, Long agentId,
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()) {
return messages;
}
messages = pruneOldToolResultsForModelInput(messages);
messages = pruneOldToolResultsForModelInput(messages, conversationId, workspaceBasePath);
int effectiveMax = (maxInputTokens != null && maxInputTokens > 0)
? 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) {
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;
for (int i = messages.size() - 1; i >= 0; i--) {
if (messages.get(i) instanceof ToolResponseMessage) {
@ -386,9 +477,13 @@ public class ConversationWindowManager {
return messages;
}
boolean canSpill = toolResultStorage != null
&& conversationId != null && !conversationId.isEmpty();
List<Message> pruned = new ArrayList<>(messages);
java.util.Set<String> seenLargeOutputs = new java.util.HashSet<>();
int changed = 0;
int spilled = 0;
for (int i = pruned.size() - 1; i >= 0; i--) {
if (!(pruned.get(i) instanceof ToolResponseMessage trm)) {
continue;
@ -398,23 +493,53 @@ public class ConversationWindowManager {
boolean messageChanged = false;
for (ToolResponseMessage.ToolResponse r : trm.getResponses()) {
String data = r.responseData();
boolean exempt = r.name() != null && PRUNE_EXEMPT_TOOLS.contains(r.name());
if (keepFull || exempt || data == null || data.length() <= OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
String name = r.name();
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);
if (data != null && data.length() > OLD_TOOL_RESULT_SUMMARY_THRESHOLD) {
if (data != null && data.length() > DEDUP_MIN_CHARS) {
seenLargeOutputs.add(data);
}
continue;
}
String replacement;
if (seenLargeOutputs.contains(data)) {
replacement = "[" + r.name() + "] duplicate tool output omitted; same content appeared later.";
} else {
replacement = summarizeToolResponse(r.name(), data);
// Dedup: identical body seen in a later turn already.
if (data.length() > DEDUP_MIN_CHARS && seenLargeOutputs.contains(data)) {
String replacement = "[" + name
+ "] 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);
}
newResponses.add(new ToolResponseMessage.ToolResponse(r.id(), r.name(), replacement));
messageChanged = true;
}
if (messageChanged) {
pruned.set(i, ToolResponseMessage.builder().responses(newResponses).build());
@ -422,37 +547,12 @@ public class ConversationWindowManager {
}
}
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;
}
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 字符
*/

View File

@ -439,7 +439,8 @@ public class StateGraphReActAgent extends BaseAgent implements StructuredStreamC
chatModel,
conversationId,
parsedAgentId,
toolSet != null ? toolSet.callbacks() : null);
toolSet != null ? toolSet.callbacks() : null,
workspaceBasePath);
}
List<Message> messages = new ArrayList<>(historyMessages);

View File

@ -348,7 +348,12 @@ public class ReasoningNode implements NodeAction {
}
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);

View File

@ -267,7 +267,8 @@ public class StateGraphPlanExecuteAgent extends BaseAgent implements StructuredS
chatModel,
conversationId,
parsedAgentId,
toolSet != null ? toolSet.callbacks() : null);
toolSet != null ? toolSet.callbacks() : null,
workspaceBasePath);
}
List<Message> messages = new ArrayList<>(historyMessages);

View File

@ -211,7 +211,11 @@ public class StepExecutionNode implements NodeAction {
ChatOptions options = oaiOpts;
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(

View File

@ -1,23 +1,51 @@
package vip.mate.agent.context;
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.ToolResponseMessage;
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 java.nio.file.Files;
import java.nio.file.Path;
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
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 {
@Test
void prunesOlderToolResultsAndKeepsLatestFullResult() {
void withoutStorageOlderLargeBodiesStayVerbatim() {
ConversationWindowManager manager = new ConversationWindowManager(
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);
List<Message> messages = List.of(
new UserMessage("read earlier file"),
@ -33,13 +61,15 @@ class ConversationWindowManagerToolPruningTest {
String oldData = oldToolMessage.getResponses().getFirst().responseData();
String latestData = latestToolMessage.getResponses().getFirst().responseData();
assertTrue(oldData.contains("previous tool output summarized"));
assertTrue(oldData.length() < 300);
// No storage keep the old body untouched, do NOT collapse to a lossy summary.
assertEquals(oldLarge, oldData,
"without storage, older tool bodies must be preserved verbatim "
+ "(the lossy single-line rewrite has been removed)");
assertEquals(latestLarge, latestData);
}
@Test
void olderDuplicateToolResultUsesDuplicatePlaceholder() {
void olderDuplicateToolResultStillUsesDuplicatePlaceholder(@TempDir Path tempDir) {
ConversationWindowManager manager = new ConversationWindowManager(
new ConversationWindowProperties(), null, null);
String repeated = "same-output\n".repeat(700);
@ -52,7 +82,149 @@ class ConversationWindowManagerToolPruningTest {
ToolResponseMessage oldToolMessage = (ToolResponseMessage) pruned.getFirst();
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) {