From ae3d27922d0908c18f1cef22c435106a129d5bf9 Mon Sep 17 00:00:00 2001 From: matevip Date: Wed, 13 May 2026 08:50:02 +0800 Subject: [PATCH] fix(context): preserve raw older tool results instead of rewriting them to a lossy summary --- .../context/ConversationWindowManager.java | 180 +++++++++++++---- .../agent/graph/StateGraphReActAgent.java | 3 +- .../mate/agent/graph/node/ReasoningNode.java | 7 +- .../plan/StateGraphPlanExecuteAgent.java | 3 +- .../graph/plan/node/StepExecutionNode.java | 6 +- ...versationWindowManagerToolPruningTest.java | 184 +++++++++++++++++- 6 files changed, 333 insertions(+), 50 deletions(-) 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 bda69ee9..8559cf5b 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 @@ -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 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}. + * + *

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 fitToWindow(List messages, String systemPrompt, + String currentUserMessage, + Integer maxInputTokens, ChatModel chatModel, + String conversationId, Long agentId, + java.util.Collection 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 pruneOldToolResultsForModelInput(List 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: + * + *

    + *
  1. Bodies already starting with {@link ToolResultStorage#SPILL_MARKER_PREFIX} + * were spilled at tool-execution time — pass through untouched.
  2. + *
  3. 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).
  4. + *
  5. 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.
  6. + *
  7. 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.
  8. + *
+ * + *

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 pruneOldToolResultsForModelInput(List 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 pruned = new ArrayList<>(messages); java.util.Set 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 字符)。 */ diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java index 6d1b5913..f845d9a3 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/StateGraphReActAgent.java @@ -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 messages = new ArrayList<>(historyMessages); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java index 5a1d3eed..acfb4a1c 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/ReasoningNode.java @@ -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); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java index 138e3257..cf04df6b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/StateGraphPlanExecuteAgent.java @@ -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 messages = new ArrayList<>(historyMessages); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java index 1b095cb7..b7f60d13 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/node/StepExecutionNode.java @@ -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( diff --git a/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerToolPruningTest.java b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerToolPruningTest.java index a2712cd2..62573812 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerToolPruningTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/context/ConversationWindowManagerToolPruningTest.java @@ -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. + * + *

The current contract: + *

+ */ 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 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 messages = List.of( + new UserMessage("turn 1"), + toolMessage("old-1", "web_search", oldLarge), + new UserMessage("turn 2"), + toolMessage("new-1", "web_search", latestLarge) + ); + + List 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 messages = List.of( + toolMessage("old-1", "web_search", oldSmall), + new UserMessage("turn"), + toolMessage("new-1", "web_search", latestLarge) + ); + + List 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 messages = List.of( + toolMessage("old-1", "web_search", alreadySpilled), + toolMessage("new-1", "web_search", latestLarge) + ); + + List 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 messages = List.of( + toolMessage("old-1", "delegateToAgent", oldLarge), // exempt tool + toolMessage("new-1", "delegateToAgent", latestLarge) + ); + + List 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 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 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) {